From 3bdaa6e10046945ccc08264d2d6f3b81775efcb3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 17 Jun 2026 23:23:14 -0700 Subject: [PATCH 001/257] 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 002/257] [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 003/257] 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 004/257] 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 005/257] 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 006/257] 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 007/257] 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 008/257] 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 009/257] 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 010/257] 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 011/257] 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 012/257] [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 013/257] 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 014/257] 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 015/257] 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 016/257] 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 017/257] 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 018/257] 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 019/257] 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 020/257] 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 021/257] [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 022/257] [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 023/257] [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 024/257] [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 025/257] 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 026/257] [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 027/257] [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 028/257] 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 029/257] [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 030/257] [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 031/257] [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 032/257] [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 033/257] [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 034/257] [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 035/257] [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 036/257] [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 037/257] [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 038/257] [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 039/257] [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 040/257] [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 041/257] [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 042/257] [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 043/257] [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 044/257] [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 045/257] 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 046/257] [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 047/257] [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 048/257] 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 049/257] 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 050/257] [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 051/257] 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 052/257] 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 053/257] [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 054/257] 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 055/257] 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 056/257] [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 057/257] [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 058/257] [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 059/257] [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 060/257] [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 061/257] [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 062/257] [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 063/257] [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 064/257] [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 065/257] [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 066/257] [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 067/257] [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 068/257] [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 069/257] [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 070/257] [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 071/257] [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 072/257] [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 073/257] [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 074/257] [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 075/257] [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 076/257] [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 077/257] [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 078/257] [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 079/257] [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 080/257] [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 081/257] [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 082/257] [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 083/257] [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 084/257] [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 085/257] [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 086/257] [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 087/257] [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 088/257] [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 089/257] [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 090/257] [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 091/257] [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 092/257] [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 093/257] [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 094/257] [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 095/257] [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 096/257] [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 097/257] [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 098/257] [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 099/257] [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 100/257] [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 101/257] [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 102/257] [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 103/257] [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 104/257] [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 105/257] [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 106/257] [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 107/257] [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 108/257] [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 109/257] [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 110/257] [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 111/257] [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 112/257] [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 113/257] [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 114/257] [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 115/257] [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 116/257] 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 117/257] [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 118/257] [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 119/257] [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 120/257] 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 121/257] [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 122/257] [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 123/257] 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 124/257] [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 125/257] [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 126/257] [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 127/257] [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 128/257] [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 129/257] [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 130/257] [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 131/257] [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 132/257] [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 133/257] [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 134/257] [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 135/257] [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 136/257] [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 137/257] [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 138/257] [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 139/257] [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 140/257] [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, }), ), ); From 4238a0e769c9f149d4083968823298ace1e2bd0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:53:53 -0700 Subject: [PATCH 141/257] [codex] Enrich Git VCS driver errors (#3253) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 7 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 108 +++++ apps/server/src/vcs/GitVcsDriverCore.ts | 479 +++++++++++-------- apps/server/src/vcs/VcsDriverRegistry.ts | 28 +- packages/contracts/src/git.ts | 7 +- 5 files changed, 407 insertions(+), 222 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 81b82d7de30..d1cfc7d2a13 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3377,13 +3377,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.map((error) => error.message), ); - expect(errorMessage).toContain("hook: fail"); + expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); + expect(errorMessage).not.toContain("hook: fail"); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "hook_started", hookName: "pre-commit", }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: fail", + }), expect.objectContaining({ kind: "action_failed", phase: "commit", diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 2fd4d447c58..dc58fc2543c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -78,6 +78,114 @@ const initRepoWithCommit = ( }); it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("structured errors", () => { + it.effect("preserves structured spawn context and the platform cause", () => + Effect.gen(function* () { + const parent = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const cwd = pathService.join(parent, "missing"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.missingCwd", + cwd, + args: ["status", "--short"], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.missingCwd", + command: "git", + argumentCount: 2, + cwd, + detail: "Failed to spawn Git process.", + }); + if (!(error.cause instanceof PlatformError.PlatformError)) { + return assert.fail("expected the original platform error cause"); + } + assert.equal(error.cause.reason._tag, "NotFound"); + assert.notInclude(error.detail, error.cause.message); + }), + ); + + it.effect("does not retain git arguments or stderr in command failures", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + const secret = "secret-token-value"; + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.redactedFailure", + cwd, + args: ["status", `--unknown-option=${secret}`], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.redactedFailure", + command: "git", + argumentCount: 2, + cwd, + }); + assert.isNumber(error.exitCode); + assert.isAbove(error.stderrLength ?? 0, 0); + assert.notInclude(error.detail, secret); + assert.notInclude(error.message, secret); + assert.notProperty(error, "args"); + assert.notProperty(error, "stderr"); + }), + ); + + it.effect("recovers a structurally identified missing cwd as a non-repository", () => + Effect.gen(function* () { + const parent = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const cwd = pathService.join(parent, "missing"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const [localStatus, remoteStatus, refs] = yield* Effect.all([ + driver.statusDetails(cwd), + driver.statusDetailsRemote(cwd, { refreshUpstream: false }), + driver.listRefs({ cwd }), + ]); + + assert.equal(localStatus.isRepo, false); + assert.equal(remoteStatus.isRepo, false); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); + }), + ); + + it.effect("does not wrap a remove-worktree command failure in a synthetic error", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const missingWorktree = pathService.join(cwd, "missing-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + const error = yield* driver + .removeWorktree({ cwd, path: missingWorktree }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.removeWorktree", + command: "git", + argumentCount: 3, + cwd, + }); + assert.notProperty(error, "cause"); + assert.notInclude(error.detail, "Git command failed in"); + }), + ); + }); + describe("review diff previews", () => { it.effect("drops an unterminated path from truncated NUL-separated git output", () => Effect.sync(() => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 23a968a9cfe..a406cbce549 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -36,7 +36,6 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; -const isGitCommandError = Schema.is(GitCommandError); const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; @@ -100,7 +99,7 @@ interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; - fallbackErrorMessage?: string | undefined; + fallbackErrorDetail?: string | undefined; env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; appendTruncationMarker?: boolean | undefined; @@ -326,8 +325,15 @@ function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { return localBranch.length > 0 ? localBranch : null; } -function commandLabel(args: readonly string[]): string { - return `git ${args.join(" ")}`; +function gitCommandContext( + input: Pick, +) { + return { + operation: input.operation, + command: "git", + cwd: input.cwd, + argumentCount: input.args.length, + } as const; } function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { @@ -340,50 +346,28 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): return refName.length > 0 ? refName : null; } -function createGitCommandError( - operation: string, - cwd: string, - args: readonly string[], - detail: string, - cause?: unknown, -): GitCommandError { - return new GitCommandError({ - operation, - command: commandLabel(args), - cwd, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} +function isMissingGitCwdError(error: GitCommandError): boolean { + if (!(error.cause instanceof PlatformError.PlatformError)) { + return false; + } -function quoteGitCommand(args: ReadonlyArray): string { - return `git ${args.join(" ")}`; -} + const reason = error.cause.reason; + if (reason._tag === "NotFound") { + return reason.pathOrDescriptor === error.cwd; + } -function isMissingGitCwdError(error: GitCommandError): boolean { - const normalized = `${error.detail}\n${error.message}`.toLowerCase(); return ( - normalized.includes("no such file or directory") || - normalized.includes("notfound: filesystem.access") || - normalized.includes("enoent") || - normalized.includes("not a directory") + reason._tag === "BadResource" && + reason.pathOrDescriptor === error.cwd && + typeof reason.cause === "object" && + reason.cause !== null && + "code" in reason.cause && + reason.cause.code === "ENOTDIR" ); } -function toGitCommandError( - input: Pick, - detail: string, -) { - return (cause: unknown) => - isGitCommandError(cause) - ? cause - : new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, - ...(cause !== undefined ? { cause } : {}), - }); +function isNonRepositoryGitStderr(stderr: string): boolean { + return stderr.toLowerCase().includes("not a git repository"); } interface Trace2Monitor { @@ -402,7 +386,11 @@ const addCurrentSpanEvent = (name: string, attributes: Record) yield* Effect.sync(() => { span.event(name, timestamp, compactTraceAttributes(attributes)); }); - }).pipe(Effect.catch(() => Effect.void)); + }).pipe( + Effect.catchTags({ + NoSuchElementError: () => Effect.void, + }), + ); function trace2ChildKey(record: Record): string | null { const childId = record.child_id; @@ -451,7 +439,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( - `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, + `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, traceRecord.failure, ); return; @@ -574,9 +562,9 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); -const collectOutput = Effect.fnUntraced(function* ( +const collectOutput = Effect.fnUntraced(function* ( input: Pick, - stream: Stream.Stream, + stream: Stream.Stream, maxOutputBytes: number, appendTruncationMarker: boolean, onLine: ((line: string) => Effect.Effect) | undefined, @@ -614,10 +602,9 @@ const collectOutput = Effect.fnUntraced(function* ( const nextBytes = bytes + chunk.byteLength; if (!appendTruncationMarker && nextBytes > maxOutputBytes) { return yield* new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + ...gitCommandContext(input), + detail: `Git output exceeded ${maxOutputBytes} bytes and was truncated.`, + outputLength: nextBytes, }); } @@ -635,7 +622,14 @@ const collectOutput = Effect.fnUntraced(function* ( }); yield* Stream.runForEach(stream, processChunk).pipe( - Effect.mapError(toGitCommandError(input, "output stream failed.")), + Effect.catchTags({ + PlatformError: (cause) => + new GitCommandError({ + ...gitCommandContext(input), + detail: "Failed to read Git process output.", + cause, + }), + }), ); const remainder = truncated ? "" : decoder.decode(); @@ -669,7 +663,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( Effect.provideService(Path.Path, path), Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to create Git trace monitor.", + cause, + }), + ), ); const child = yield* commandSpawner .spawn( @@ -682,7 +683,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }), ) - .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); + .pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to spawn Git process.", + cause, + }), + ), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ @@ -701,12 +711,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.progress?.onStderrLine, ), child.exitCode.pipe( - Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to read Git process exit code.", + cause, + }), + ), ), input.stdin === undefined ? Effect.void : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( - Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to write Git process input.", + cause, + }), + ), ), ], { concurrency: "unbounded" }, @@ -714,15 +738,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* trace2Monitor.flush; if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.text.trim(); return yield* new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + ...gitCommandContext(commandInput), + detail: "Git command exited with a non-zero status.", + exitCode, + stdoutLength: stdout.text.length, + stderrLength: stderr.text.length, }); } @@ -743,10 +764,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* onNone: () => Effect.fail( new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: `${quoteGitCommand(commandInput.args)} timed out.`, + ...gitCommandContext(commandInput), + detail: "Git command timed out.", }), ), onSome: Effect.succeed, @@ -799,22 +818,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (options.allowNonZeroExit || result.exitCode === 0) { return Effect.succeed(result); } - const stderr = result.stderr.trim(); - if (stderr.length > 0) { - return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); - } - if (options.fallbackErrorMessage) { - return Effect.fail( - createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), - ); - } return Effect.fail( - createGitCommandError( - operation, - cwd, - args, - `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`, - ), + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args }), + detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), ); }), ); @@ -877,12 +888,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - return yield* createGitCommandError( - "GitVcsDriver.renameBranch", - cwd, - ["branch", "-m", "--", desiredBranch], - `Could not find an available branch name for '${desiredBranch}'.`, - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.renameBranch", + cwd, + args: ["branch", "-m", "--", desiredBranch], + }), + detail: `Could not find an available branch name for '${desiredBranch}'.`, + }); }); const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { @@ -1024,12 +1037,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (firstRemote) { return firstRemote; } - return yield* createGitCommandError( - "GitVcsDriver.resolvePrimaryRemoteName", - cwd, - ["remote"], - "No git remote is configured for this repository.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolvePrimaryRemoteName", + cwd, + args: ["remote"], + }), + detail: "No git remote is configured for this repository.", + }); }); const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( @@ -1174,19 +1189,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, ["rev-parse", "--abbrev-ref", "HEAD"], { allowNonZeroExit: true }, - ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); if (branchResult === null) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } if (branchResult.exitCode !== 0) { - const stderr = branchResult.stderr.trim(); - return yield* createGitCommandError( - "GitVcsDriver.statusDetailsRemote.branch", - cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], - stderr || "git branch lookup failed", - ); + if (isNonRepositoryGitStderr(branchResult.stderr)) { + return NON_REPOSITORY_REMOTE_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetailsRemote.branch", + cwd, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + }), + detail: "Git branch lookup failed.", + exitCode: branchResult.exitCode, + stdoutLength: branchResult.stdout.length, + stderrLength: branchResult.stderr.length, + }); } const branchValue = branchResult.stdout.trim(); @@ -1284,20 +1311,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { allowNonZeroExit: true, }, - ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); if (statusResult === null) { return NON_REPOSITORY_STATUS_DETAILS; } if (statusResult.exitCode !== 0) { - const stderr = statusResult.stderr.trim(); - return yield* createGitCommandError( - "GitVcsDriver.statusDetails.status", - cwd, - ["status", "--porcelain=2", "--branch"], - stderr || "git status failed", - ); + if (isNonRepositoryGitStderr(statusResult.stderr)) { + return NON_REPOSITORY_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.status", + cwd, + args: ["status", "--porcelain=2", "--branch"], + }), + detail: "Git status failed.", + exitCode: statusResult.exitCode, + stdoutLength: statusResult.stdout.length, + stderrLength: statusResult.stderr.length, + }); } const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = @@ -1436,7 +1475,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "statusDetails", )(function* (cwd) { yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), + }), Effect.ignoreCause({ log: true }), ); return yield* readStatusDetailsLocal(cwd); @@ -1446,7 +1488,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.fn("statusDetailsRemote")(function* (cwd, options) { if (options?.refreshUpstream !== false) { yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), + }), Effect.ignoreCause({ log: true }), ); } @@ -1474,7 +1519,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { if (filePaths && filePaths.length > 0) { yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catch(() => Effect.void), + Effect.catchTags({ + GitCommandError: () => Effect.void, + }), ); yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ "add", @@ -1550,12 +1597,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const details = yield* statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push from detached HEAD.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push from detached HEAD.", + }); } const requestedRemoteName = options?.remoteName?.trim() || null; @@ -1614,12 +1663,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (!details.hasUpstream) { const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); if (!publishRemoteName) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push because no git remote is configured for this repository.", + }); } const publishBranch = yield* resolvePublishBranchName(cwd, branch); yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ @@ -1668,20 +1719,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const details = yield* statusDetails(cwd); const refName = details.branch; if (!refName) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pullCurrentBranch", + cwd, + args: ["pull", "--ff-only"], + }), + detail: "Cannot pull from detached HEAD.", + }); } if (!details.hasUpstream) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pullCurrentBranch", + cwd, + args: ["pull", "--ff-only"], + }), + detail: "Current branch has no upstream configured. Push with upstream first.", + }); } const beforeSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.beforeSha", @@ -1691,7 +1746,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => stdout.trim())); yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", + fallbackErrorDetail: "git pull failed", }); const afterSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.afterSha", @@ -1874,14 +1929,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe( Effect.map(Encoding.encodeHex), Effect.mapError( - toGitCommandError( - { + (cause) => + new GitCommandError({ operation: "GitVcsDriver.getReviewDiffPreview.hash", + command: "crypto.digest SHA-256", cwd: input.cwd, - args: [], - }, - "failed to hash review diff.", - ), + detail: "Failed to hash review diff.", + cause, + }), ), ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ @@ -1939,20 +1994,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catchIf(isMissingGitCwdError, () => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(128), - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) + ? Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(128), + stdout: "", + stderr: "fatal: not a git repository", + stdoutTruncated: false, + stderrTruncated: false, + }) + : Effect.fail(error), + }), ); if (localBranchResult.exitCode !== 0) { const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { + if (isNonRepositoryGitStderr(stderr)) { return { refs: [], isRepo: false, @@ -1961,12 +2019,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* totalCount: 0, }; } - return yield* createGitCommandError( - "GitVcsDriver.listRefs", - input.cwd, - ["branch", "--no-color", "--no-column"], - stderr || "git branch failed", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.listRefs", + cwd: input.cwd, + args: ["branch", "--no-color", "--no-column"], + }), + detail: "Git branch listing failed.", + exitCode: localBranchResult.exitCode, + stdoutLength: localBranchResult.stdout.length, + stderrLength: localBranchResult.stderr.length, + }); } const remoteBranchResultEffect = executeGit( @@ -1978,19 +2041,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - ), + Effect.catchTags({ + GitCommandError: (error) => + Effect.logWarning( + "Git remote ref lookup failed; falling back to an empty remote ref list.", + { + operation: error.operation, + command: error.command, + cwd: error.cwd, + detail: error.detail, + cause: error, + }, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + }), ); const remoteNamesResultEffect = executeGit( @@ -2002,19 +2073,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - ), + Effect.catchTags({ + GitCommandError: (error) => + Effect.logWarning( + "Git remote name lookup failed; falling back to an empty remote name list.", + { + operation: error.operation, + command: error.command, + cwd: error.cwd, + detail: error.detail, + cause: error, + }, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + }), ); const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = @@ -2175,7 +2254,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : ["worktree", "add", worktreePath, input.refName]; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", + fallbackErrorDetail: "git worktree add failed", }); if (input.newRefName && input.baseRefName) { @@ -2214,7 +2293,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, ], { - fallbackErrorMessage: "git fetch pull request branch failed", + fallbackErrorDetail: "git fetch pull request branch failed", }, ); }); @@ -2227,7 +2306,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["fetch", "--quiet", input.remoteName], { env: STATUS_UPSTREAM_REFRESH_ENV, - fallbackErrorMessage: `git fetch ${input.remoteName} failed`, + fallbackErrorDetail: `git fetch ${input.remoteName} failed`, }, ); }, @@ -2302,18 +2381,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args.push(input.path); yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitVcsDriver.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, - error, - ), - ), - ); + fallbackErrorDetail: "git worktree remove failed", + }); }); const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( @@ -2330,7 +2399,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["branch", "-m", "--", input.oldBranch, targetBranch], { timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", + fallbackErrorDetail: "git branch rename failed", }, ); @@ -2407,7 +2476,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", + fallbackErrorDetail: "git checkout failed", }); const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ @@ -2423,7 +2492,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* function* (input) { yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", + fallbackErrorDetail: "git branch create failed", }); if (input.switchRef) { yield* switchRef({ cwd: input.cwd, refName: input.refName }); @@ -2436,7 +2505,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, - fallbackErrorMessage: "git init failed", + fallbackErrorDetail: "git init failed", }).pipe(Effect.asVoid); const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = ( diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 103cc9607c1..0bf95c2ffba 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -36,13 +36,6 @@ export class VcsDriverRegistry extends Context.Service< } >()("t3/vcs/VcsDriverRegistry") {} -const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => - new VcsUnsupportedOperationError({ - operation, - kind, - detail, - }); - function detectionCacheKey(input: { readonly cwd: string; readonly requestedKind: VcsDriverKind | "auto"; @@ -78,7 +71,11 @@ export const make = Effect.gen(function* () { const driver = drivers[kind]; if (!driver) { return Effect.fail( - unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`), + new VcsUnsupportedOperationError({ + operation: "VcsDriverRegistry.get", + kind, + detail: `No ${kind} VCS driver is registered.`, + }), ); } return Effect.succeed(driver); @@ -137,13 +134,14 @@ export const make = Effect.gen(function* () { } const requestedKind = input.requestedKind ?? "auto"; - return yield* unsupported( - "VcsDriverRegistry.resolve", - requestedKind === "auto" ? "unknown" : requestedKind, - requestedKind === "auto" - ? `No supported VCS repository was detected at ${input.cwd}.` - : `No ${requestedKind} repository was detected at ${input.cwd}.`, - ); + return yield* new VcsUnsupportedOperationError({ + operation: "VcsDriverRegistry.resolve", + kind: requestedKind === "auto" ? "unknown" : requestedKind, + detail: + requestedKind === "auto" + ? `No supported VCS repository was detected at ${input.cwd}.` + : `No ${requestedKind} repository was detected at ${input.cwd}.`, + }); }, ); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 7ee2a571963..38aba54277c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -324,11 +324,16 @@ export class GitCommandError extends Schema.TaggedErrorClass()( operation: Schema.String, command: Schema.String, cwd: Schema.String, + argumentCount: Schema.optional(Schema.Number), + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + outputLength: Schema.optional(Schema.Number), detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { override get message(): string { - return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}`; } } From 430ece4c0aa49ede37bb3bd29f451fde97b1b64a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:54:38 -0700 Subject: [PATCH 142/257] [codex] Structure Codex app-server request errors (#3258) Co-authored-by: codex --- .../Layers/CodexSessionRuntime.test.ts | 174 +++++++----- .../provider/Layers/CodexSessionRuntime.ts | 59 ++-- .../src/_internal/shared.test.ts | 151 +++++++++++ .../src/_internal/shared.ts | 36 +-- .../src/_internal/stdio.ts | 2 +- .../effect-codex-app-server/src/errors.ts | 256 +++++++++++++++++- .../src/protocol.test.ts | 61 ++++- .../effect-codex-app-server/src/protocol.ts | 50 ++-- 8 files changed, 631 insertions(+), 158 deletions(-) create mode 100644 packages/effect-codex-app-server/src/_internal/shared.test.ts diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 06b7dd99bd4..8aeacd870cc 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1,8 +1,9 @@ import * as NodeAssert from "node:assert/strict"; +import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { describe, it } from "vite-plus/test"; +import { describe } from "vite-plus/test"; import { ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; @@ -19,6 +20,23 @@ import { } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +describe("CodexSessionRuntimeIdentifierGenerationError", () => { + it("retains identifier purpose and the random source failure", () => { + const cause = new Error("random source unavailable"); + const error = new CodexErrors.CodexAppServerIdentifierGenerationError({ + purpose: "provider-event", + cause, + }); + + NodeAssert.equal(error.purpose, "provider-event"); + NodeAssert.strictEqual(error.cause, cause); + NodeAssert.equal( + error.message, + "Failed to generate Codex App Server identifier for provider-event.", + ); + }); +}); + function makeThreadOpenResponse( threadId: string, ): CodexRpc.ClientRequestResponsesByMethod["thread/start"] { @@ -43,6 +61,32 @@ function makeThreadOpenResponse( } describe("buildTurnStartParams", () => { + it("keeps invalid turn values only in the schema cause", () => { + const secret = "codex-turn-input-secret-sentinel"; + const error = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + attachments: [ + { + type: "image", + url: { secret } as unknown as string, + }, + ], + }).pipe(Effect.flip), + ); + const { cause, ...directDiagnostics } = error; + + NodeAssert.equal(error.operation, "decode-request-payload"); + NodeAssert.equal(error.method, "turn/start"); + NodeAssert.ok((error.issueCount ?? 0) > 0); + NodeAssert.ok(error.issueKinds?.includes("Pointer")); + NodeAssert.ok((error.maximumPathDepth ?? 0) > 0); + NodeAssert.ok(Schema.isSchemaError(cause)); + NodeAssert.doesNotMatch(error.message, new RegExp(secret)); + NodeAssert.doesNotMatch(JSON.stringify(directDiagnostics), new RegExp(secret)); + }); + it("includes plan collaboration mode when requested", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -223,29 +267,29 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { - it("falls back to thread/start when resume fails recoverably", async () => { - const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; - const started = makeThreadOpenResponse("fresh-thread"); - const client = { - request: ( - method: M, - payload: CodexRpc.ClientRequestParamsByMethod[M], - ) => { - calls.push({ method, payload }); - if (method === "thread/resume") { - return Effect.fail( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "thread not found", - }), - ); - } - return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); - }, - }; + it.effect("falls back to thread/start when resume fails recoverably", () => + Effect.gen(function* () { + const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; + const started = makeThreadOpenResponse("fresh-thread"); + const client = { + request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + calls.push({ method, payload }); + if (method === "thread/resume") { + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread not found", + }), + ); + } + return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); + }, + }; - const opened = await Effect.runPromise( - openCodexThread({ + const opened = yield* openCodexThread({ client, threadId: ThreadId.make("thread-1"), runtimeMode: "full-access", @@ -253,51 +297,49 @@ describe("openCodexThread", () => { requestedModel: "gpt-5.3-codex", serviceTier: undefined, resumeThreadId: "stale-thread", - }), - ); + }); - NodeAssert.equal(opened.thread.id, "fresh-thread"); - NodeAssert.deepStrictEqual( - calls.map((call) => call.method), - ["thread/resume", "thread/start"], - ); - }); + NodeAssert.equal(opened.thread.id, "fresh-thread"); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["thread/resume", "thread/start"], + ); + }), + ); - it("propagates non-recoverable resume failures", async () => { - const client = { - request: ( - method: M, - _payload: CodexRpc.ClientRequestParamsByMethod[M], - ) => { - if (method === "thread/resume") { - return Effect.fail( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "timed out waiting for server", - }), + it.effect("propagates non-recoverable resume failures", () => + Effect.gen(function* () { + const client = { + request: ( + method: M, + _payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + if (method === "thread/resume") { + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "timed out waiting for server", + }), + ); + } + return Effect.succeed( + makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], ); - } - return Effect.succeed( - makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], - ); - }, - }; + }, + }; - await NodeAssert.rejects( - Effect.runPromise( - openCodexThread({ - client, - threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", - cwd: "/tmp/project", - requestedModel: "gpt-5.3-codex", - serviceTier: undefined, - resumeThreadId: "stale-thread", - }), - ), - (error: unknown) => - isCodexAppServerRequestError(error) && - error.errorMessage === "timed out waiting for server", - ); - }); + const error = yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: "stale-thread", + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexAppServerRequestError(error)); + NodeAssert.equal(error.errorMessage, "timed out waiting for server"); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 03957081ded..99ac498f0c3 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -26,10 +26,9 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as SchemaIssue from "effect/SchemaIssue"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -89,7 +88,6 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; type CodexServiceTier = NonNullable; @@ -390,7 +388,13 @@ export function buildTurnStartParams(input: { ...(input.effort ? { effort: input.effort } : {}), ...(collaborationMode ? { collaborationMode } : {}), }).pipe( - Effect.mapError((error) => toProtocolParseError("Invalid turn/start request payload", error)), + Effect.mapError((cause) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-request-payload", + cause, + { method: "turn/start" }, + ), + ), ); } @@ -468,7 +472,7 @@ export const openCodexThread = (input: { requestedRuntimeMode: input.runtimeMode, resumeThreadId, recoverable: true, - cause: error.message, + cause: error, }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), ), ); @@ -658,16 +662,6 @@ function toCodexUserInputAnswers( ).pipe(Effect.map((entries) => Object.fromEntries(entries))); } -function toProtocolParseError( - detail: string, - cause: Schema.SchemaError, -): CodexErrors.CodexAppServerProtocolParseError { - return new CodexErrors.CodexAppServerProtocolParseError({ - detail: `${detail}: ${formatSchemaIssue(cause.issue)}`, - cause, - }); -} - function currentProviderThreadId(session: ProviderSession): string | undefined { return readResumeCursorThreadId(session.resumeCursor); } @@ -760,15 +754,16 @@ export const makeCodexSessionRuntime = ( ); const serverNotifications = yield* Queue.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new CodexErrors.CodexAppServerTransportError({ - detail: "Failed to generate Codex runtime identifier.", - cause, - }), - ), - ); + const randomUUIDv4 = (purpose: CodexErrors.CodexAppServerIdentifierPurpose) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerIdentifierGenerationError({ + purpose, + cause, + }), + ), + ); const sessionCreatedAt = yield* nowIso; const initialSession = { @@ -788,7 +783,7 @@ export const makeCodexSessionRuntime = ( const emitEvent = (event: Omit) => Effect.gen(function* () { - const id = yield* randomUUIDv4; + const id = yield* randomUUIDv4("provider-event"); return yield* offerEvent({ id: EventId.make(id), provider: PROVIDER, @@ -956,7 +951,7 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("command-approval-request")); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const decision = yield* Deferred.make(); @@ -1012,7 +1007,9 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const requestId = ApprovalRequestId.make( + yield* randomUUIDv4("file-change-approval-request"), + ); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const decision = yield* Deferred.make(); @@ -1068,7 +1065,7 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const answers = yield* Deferred.make(); @@ -1293,7 +1290,11 @@ export const makeCodexSessionRuntime = ( const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( Effect.mapError((error) => - toProtocolParseError("Invalid turn/start response payload", error), + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "turn/start" }, + ), ), ); const turnId = TurnId.make(response.turn.id); diff --git a/packages/effect-codex-app-server/src/_internal/shared.test.ts b/packages/effect-codex-app-server/src/_internal/shared.test.ts new file mode 100644 index 00000000000..62b1373f12c --- /dev/null +++ b/packages/effect-codex-app-server/src/_internal/shared.test.ts @@ -0,0 +1,151 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as CodexError from "../errors.ts"; +import * as Shared from "./shared.ts"; + +const decodeNestedNumberPayload = Schema.decodeUnknownEffect( + Schema.Struct({ profile: Schema.Struct({ token: Schema.Number }) }), +); +const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +it.effect("preserves schema decode diagnostics without deriving the message from the cause", () => + Effect.gen(function* () { + const error = yield* Shared.decodeOptionalPayload("thread/start", Schema.String, 42).pipe( + Effect.flip, + ); + + assert.instanceOf(error, CodexError.CodexAppServerRequestError); + assert.equal(error.code, -32602); + assert.equal(error.method, "thread/start"); + assert.equal(error.operation, "decode-payload"); + assert.equal( + error.message, + "Invalid payload for method 'thread/start' during 'decode-payload'", + ); + assert.isTrue(Schema.isSchemaError(error.cause)); + + const protocolError = error.toProtocolError(); + assert.equal(protocolError.code, -32602); + assert.equal(protocolError.message, error.message); + assert.property(protocolError, "data"); + assert.notProperty(protocolError, "method"); + assert.notProperty(protocolError, "operation"); + assert.notProperty(protocolError, "cause"); + }), +); + +it.effect("preserves schema encode diagnostics", () => + Effect.gen(function* () { + const error = yield* Shared.encodeOptionalPayload( + "thread/start", + Schema.Number, + "not-a-number" as never, + ).pipe(Effect.flip); + + assert.equal(error.method, "thread/start"); + assert.equal(error.operation, "encode-payload"); + assert.equal( + error.message, + "Invalid payload for method 'thread/start' during 'encode-payload'", + ); + assert.isTrue(Schema.isSchemaError(error.cause)); + }), +); + +it.effect("does not invent a cause when a method has no payload schema", () => + Effect.gen(function* () { + const secret = "unexpected-payload-secret"; + const error = yield* Shared.decodeOptionalPayload("initialized", undefined, { + token: secret, + }).pipe(Effect.flip); + + assert.equal(error.method, "initialized"); + assert.equal(error.operation, "decode-payload"); + assert.equal(error.payloadKind, "object"); + assert.deepEqual(error.data, { payloadKind: "object" }); + assert.isUndefined(error.cause); + assert.notInclude(error.message, secret); + assert.notInclude(encodeUnknownJson(error.toProtocolError()), secret); + }), +); + +it.effect("keeps invalid payload values only in the exact schema cause", () => + Effect.gen(function* () { + const secret = "codex-schema-payload-secret"; + const cause = yield* decodeNestedNumberPayload({ profile: { token: secret } }).pipe( + Effect.flip, + ); + const error = CodexError.CodexAppServerRequestError.invalidPayload( + "thread/start", + "decode-payload", + cause, + ); + const { cause: directCause, ...directDiagnostics } = error; + + assert.strictEqual(directCause, cause); + assert.equal(error.method, "thread/start"); + assert.equal(error.operation, "decode-payload"); + assert.equal(error.maximumPathDepth, 2); + assert.isAbove(error.issueCount ?? 0, 0); + assert.include(error.issueKinds ?? [], "Pointer"); + assert.notInclude(error.message, secret); + assert.notInclude(encodeUnknownJson(directDiagnostics), secret); + assert.notInclude(encodeUnknownJson(error.toProtocolError()), secret); + }), +); + +it.effect("retains the request-handler error as the internal error cause", () => + Effect.gen(function* () { + const rootCause = new Error("socket closed"); + const source = new CodexError.CodexAppServerTransportError({ + operation: "read-input-stream", + cause: rootCause, + }); + const error = yield* Shared.runHandler( + (_payload: void) => Effect.fail(source), + undefined, + "thread/start", + ).pipe(Effect.flip); + + assert.equal(error.code, -32603); + assert.equal(error.method, "thread/start"); + assert.equal(error.operation, "handle-request"); + assert.equal( + error.message, + "Codex App Server request handler failed for method 'thread/start'", + ); + assert.strictEqual(error.cause, source); + assert.strictEqual(source.cause, rootCause); + assert.notInclude(error.message, source.message); + }), +); + +it.effect("passes request errors through without adding a wrapper", () => + Effect.gen(function* () { + const source = CodexError.CodexAppServerRequestError.invalidParams("Invalid thread id"); + const error = yield* Shared.runHandler( + (_payload: void) => Effect.fail(source), + undefined, + "thread/start", + ).pipe(Effect.flip); + + assert.strictEqual(error, source); + }), +); + +it.effect("retains the full notification payload decode cause chain", () => + Effect.gen(function* () { + const error = yield* Shared.decodeNotificationPayload( + "item/agentMessage/delta", + Schema.String, + 42, + ).pipe(Effect.flip); + + assert.equal(error.method, "item/agentMessage/delta"); + assert.equal(error.operation, "decode-notification-payload"); + assert.instanceOf(error.cause, CodexError.CodexAppServerRequestError); + assert.isTrue(Schema.isSchemaError(error.cause.cause)); + }), +); diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 99fe8e5360b..34155348abf 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -1,11 +1,8 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import * as CodexError from "../errors.ts"; -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); - export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); export const JsonRpcError = Schema.Struct({ @@ -30,16 +27,13 @@ export const decodeOptionalPayload = ( return Effect.sync(() => undefined as A); } return Effect.fail( - CodexError.CodexAppServerRequestError.invalidParams(`${method} does not accept params`, raw), + CodexError.CodexAppServerRequestError.unexpectedPayload(method, "decode-payload", raw), ); } return Schema.decodeUnknownEffect(schema)(raw).pipe( Effect.mapError((error) => - CodexError.CodexAppServerRequestError.invalidParams( - `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, - { issue: error.issue }, - ), + CodexError.CodexAppServerRequestError.invalidPayload(method, "decode-payload", error), ), ); }; @@ -54,19 +48,13 @@ export const encodeOptionalPayload = ( return Effect.sync(() => undefined); } return Effect.fail( - CodexError.CodexAppServerRequestError.invalidParams( - `${method} does not accept params`, - payload, - ), + CodexError.CodexAppServerRequestError.unexpectedPayload(method, "encode-payload", payload), ); } return Schema.encodeEffect(schema)(payload).pipe( Effect.mapError((error) => - CodexError.CodexAppServerRequestError.invalidParams( - `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, - { issue: error.issue }, - ), + CodexError.CodexAppServerRequestError.invalidPayload(method, "encode-payload", error), ), ); }; @@ -77,12 +65,12 @@ export const decodeNotificationPayload = ( raw: unknown, ): Effect.Effect => decodeOptionalPayload(method, schema, raw).pipe( - Effect.mapError( - (error) => - new CodexError.CodexAppServerProtocolParseError({ - detail: error.message, - cause: error, - }), + Effect.mapError((error) => + CodexError.CodexAppServerProtocolParseError.fromRequestError( + "decode-notification-payload", + method, + error, + ), ), ); @@ -96,6 +84,8 @@ export const runHandler = Effect.fnUntraced(function* ( } return yield* handler(payload).pipe( - Effect.mapError((error) => CodexError.normalizeToRequestError(error)), + Effect.mapError((error) => + CodexError.CodexAppServerRequestError.fromAppServerError(error, method), + ), ); }); diff --git a/packages/effect-codex-app-server/src/_internal/stdio.ts b/packages/effect-codex-app-server/src/_internal/stdio.ts index ced07fb53e2..9167129db5c 100644 --- a/packages/effect-codex-app-server/src/_internal/stdio.ts +++ b/packages/effect-codex-app-server/src/_internal/stdio.ts @@ -50,7 +50,7 @@ export const makeTerminationError = ( Effect.match(handle.exitCode, { onFailure: (cause) => new CodexError.CodexAppServerTransportError({ - detail: "Failed to determine Codex App Server process exit status", + operation: "read-process-exit-status", cause, }), onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }), diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index d9977c63597..2f769f47de2 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -1,4 +1,119 @@ import * as Schema from "effect/Schema"; +import type * as SchemaIssue from "effect/SchemaIssue"; + +export const CodexAppServerRequestOperation = Schema.Literals([ + "decode-payload", + "encode-payload", + "handle-request", +]); +export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; + +export const CodexAppServerSchemaIssueKind = Schema.Literals([ + "Filter", + "Encoding", + "Pointer", + "Composite", + "AnyOf", + "InvalidType", + "InvalidValue", + "MissingKey", + "UnexpectedKey", + "Forbidden", + "OneOf", +]); +export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; + +export interface CodexAppServerSchemaIssueDiagnostics { + readonly issueCount: number; + readonly issueKinds: ReadonlyArray; + readonly maximumPathDepth: number; +} + +const schemaIssueDiagnostics = (root: SchemaIssue.Issue): CodexAppServerSchemaIssueDiagnostics => { + let issueCount = 0; + let maximumPathDepth = 0; + const issueKinds = new Set(); + + const visit = (issue: SchemaIssue.Issue, pathDepth: number): void => { + issueCount += 1; + issueKinds.add(issue._tag); + maximumPathDepth = Math.max(maximumPathDepth, pathDepth); + switch (issue._tag) { + case "Filter": + case "Encoding": + visit(issue.issue, pathDepth); + break; + case "Pointer": + visit(issue.issue, pathDepth + issue.path.length); + break; + case "Composite": + case "AnyOf": + for (const child of issue.issues) visit(child, pathDepth); + break; + } + }; + + visit(root, 0); + return { + issueCount, + issueKinds: [...issueKinds], + maximumPathDepth, + }; +}; + +export const CodexAppServerPayloadKind = Schema.Literals([ + "null", + "array", + "string", + "number", + "boolean", + "bigint", + "object", + "symbol", + "function", + "undefined", +]); +export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; + +const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { + if (payload === null) return "null"; + if (Array.isArray(payload)) return "array"; + return typeof payload; +}; + +export interface CodexAppServerRequestDiagnostics { + readonly method?: string; + readonly operation?: CodexAppServerRequestOperation; + readonly cause?: unknown; + readonly issueCount?: number; + readonly issueKinds?: ReadonlyArray; + readonly maximumPathDepth?: number; + readonly payloadKind?: CodexAppServerPayloadKind; +} + +export const CodexAppServerProtocolParseOperation = Schema.Literals([ + "encode-wire-message", + "decode-wire-message", + "route-wire-message", + "decode-notification-payload", + "decode-request-payload", + "decode-response-payload", +]); +export type CodexAppServerProtocolParseOperation = typeof CodexAppServerProtocolParseOperation.Type; + +export const CodexAppServerTransportOperation = Schema.Literals([ + "read-input-stream", + "read-process-exit-status", +]); +export type CodexAppServerTransportOperation = typeof CodexAppServerTransportOperation.Type; + +export const CodexAppServerIdentifierPurpose = Schema.Literals([ + "provider-event", + "command-approval-request", + "file-change-approval-request", + "user-input-request", +]); +export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type; export interface CodexAppServerProtocolErrorShape { readonly code: number; @@ -37,24 +152,79 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass()( "CodexAppServerProtocolParseError", { - detail: Schema.String, + operation: CodexAppServerProtocolParseOperation, + method: Schema.optionalKey(Schema.String), + detail: Schema.optionalKey(Schema.String), + issueCount: Schema.optionalKey(Schema.Number), + issueKinds: Schema.optionalKey(Schema.Array(CodexAppServerSchemaIssueKind)), + maximumPathDepth: Schema.optionalKey(Schema.Number), cause: Schema.optional(Schema.Defect()), }, ) { override get message() { - return `Failed to parse Codex App Server protocol message: ${this.detail}`; + const method = this.method === undefined ? "" : ` for method '${this.method}'`; + return `Codex App Server protocol operation '${this.operation}' failed${method}.`; + } + + static fromSchemaError( + operation: CodexAppServerProtocolParseOperation, + cause: Schema.SchemaError, + context: { readonly method?: string } = {}, + ) { + return new CodexAppServerProtocolParseError({ + operation, + ...context, + ...schemaIssueDiagnostics(cause.issue), + cause, + }); + } + + static fromRequestError( + operation: CodexAppServerProtocolParseOperation, + method: string, + cause: CodexAppServerRequestError, + ) { + return new CodexAppServerProtocolParseError({ + operation, + method, + ...(cause.issueCount === undefined ? {} : { issueCount: cause.issueCount }), + ...(cause.issueKinds === undefined ? {} : { issueKinds: cause.issueKinds }), + ...(cause.maximumPathDepth === undefined ? {} : { maximumPathDepth: cause.maximumPathDepth }), + cause, + }); } } export class CodexAppServerTransportError extends Schema.TaggedErrorClass()( "CodexAppServerTransportError", { - detail: Schema.String, + operation: CodexAppServerTransportOperation, cause: Schema.Defect(), }, ) { override get message() { - return this.detail; + return `Codex App Server transport operation '${this.operation}' failed.`; + } +} + +export class CodexAppServerIdentifierGenerationError extends Schema.TaggedErrorClass()( + "CodexAppServerIdentifierGenerationError", + { + purpose: CodexAppServerIdentifierPurpose, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to generate Codex App Server identifier for ${this.purpose}.`; + } +} + +export class CodexAppServerInputStreamEndedError extends Schema.TaggedErrorClass()( + "CodexAppServerInputStreamEndedError", + {}, +) { + override get message() { + return "Codex App Server input stream ended."; } } @@ -64,6 +234,13 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, CodexError.CodexAppServerProtocolParseError); - assert.equal(bigintError.detail, "Failed to encode Codex App Server message"); + assert.equal(bigintError.operation, "encode-wire-message"); + assert.exists(bigintError.cause); + assert.equal( + bigintError.message, + "Codex App Server protocol operation 'encode-wire-message' failed.", + ); const circular: Record = {}; circular.self = circular; const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, CodexError.CodexAppServerProtocolParseError); - assert.equal(circularError.detail, "Failed to encode Codex App Server message"); + assert.equal(circularError.operation, "encode-wire-message"); + assert.exists(circularError.cause); + }), + ); + + it.effect("logs decode failures without copying the cause or wire payload", () => + Effect.gen(function* () { + const secret = "codex-wire-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const events: Array = []; + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + logIncoming: true, + logger: (event) => + Effect.sync(() => { + events.push(event); + }), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer(input, encoder.encode(`{"secret":"${secret}"\n`)); + yield* Deferred.await(termination); + + const event = events.find(({ stage }) => stage === "decode_failed"); + assert.exists(event); + assert.equal(event.direction, "incoming"); + const payload = event.payload as Record; + assert.equal(payload.operation, "decode-wire-message"); + assert.isNumber(payload.issueCount); + assert.isArray(payload.issueKinds); + assert.isNumber(payload.maximumPathDepth); + assert.equal("cause" in payload, false); + assert.equal("detail" in payload, false); + assert.notInclude(encodeUnknownJsonString(event), secret); + }), + ); + + it.effect("classifies an input stream ending without inventing a cause", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.end(input); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, CodexError.CodexAppServerInputStreamEndedError); + assert.equal(error.message, "Codex App Server input stream ended."); + assert.equal("cause" in error, false); }), ); }); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index 0fc2ce73c5c..c0f07f95a5a 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -94,12 +94,8 @@ const encodeWireMessage = ( ): Effect.Effect => encodeJsonString(message).pipe( Effect.map((encoded) => `${encoded}\n`), - Effect.mapError( - (cause) => - new CodexError.CodexAppServerProtocolParseError({ - detail: "Failed to encode Codex App Server message", - cause, - }), + Effect.mapError((cause) => + CodexError.CodexAppServerProtocolParseError.fromSchemaError("encode-wire-message", cause), ), ); @@ -107,20 +103,19 @@ const decodeWireMessage = ( line: string, ): Effect.Effect => decodeJsonString(line).pipe( - Effect.mapError( - (cause) => - new CodexError.CodexAppServerProtocolParseError({ - detail: "Failed to decode Codex App Server wire message", - cause, - }), + Effect.mapError((cause) => + CodexError.CodexAppServerProtocolParseError.fromSchemaError("decode-wire-message", cause), ), ); -const normalizeIncomingError = (error: unknown, detail: string): CodexError.CodexAppServerError => +const normalizeIncomingError = ( + error: unknown, + operation: CodexError.CodexAppServerTransportOperation, +): CodexError.CodexAppServerError => isCodexAppServerError(error) ? error : new CodexError.CodexAppServerTransportError({ - detail, + operation, cause: error, }); @@ -262,7 +257,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa ? options.onRequest(request).pipe( Effect.matchEffect({ onFailure: (error) => - respondError(request.id, CodexError.normalizeToRequestError(error)), + respondError( + request.id, + CodexError.CodexAppServerRequestError.fromAppServerError( + error, + request.method, + ), + ), onSuccess: (result) => respond(request.id, result), }), ) @@ -292,6 +293,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa return Effect.fail( new CodexError.CodexAppServerProtocolParseError({ detail: "Received protocol message in an unknown shape", + operation: "route-wire-message", }), ); }; @@ -318,8 +320,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa direction: "incoming", stage: "decode_failed", payload: { - detail: error.detail, - cause: error.cause, + operation: error.operation, + ...(error.method === undefined ? {} : { method: error.method }), + ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), + ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), + ...(error.maximumPathDepth === undefined + ? {} + : { maximumPathDepth: error.maximumPathDepth }), }, }), ), @@ -340,7 +347,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.matchEffect({ onFailure: (error) => handleTermination(() => - Effect.succeed(normalizeIncomingError(error, "Codex App Server input stream failed")), + Effect.succeed(normalizeIncomingError(error, "read-input-stream")), ), onSuccess: () => Ref.get(remainder).pipe( @@ -351,12 +358,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa handleTermination( () => options.terminationError ?? - Effect.succeed( - new CodexError.CodexAppServerTransportError({ - detail: "Codex App Server input stream ended", - cause: new Error("Codex App Server input stream ended"), - }), - ), + Effect.succeed(new CodexError.CodexAppServerInputStreamEndedError({})), ), }), ), From 90bd5ee0c13782e56ccbeded52b0cc6c50deafad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:55:10 -0700 Subject: [PATCH 143/257] [codex] structure desktop preview failures (#3244) Co-authored-by: codex --- apps/desktop/src/preview/Manager.test.ts | 211 +++- apps/desktop/src/preview/Manager.ts | 1119 +++++++++++------ .../preview/PlaywrightInjectedRuntime.test.ts | 54 + .../src/preview/PlaywrightInjectedRuntime.ts | 220 +++- 4 files changed, 1189 insertions(+), 415 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 81b98f4f4e8..cc83d5f2e37 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -7,9 +7,10 @@ import * as Fiber from "effect/Fiber"; 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 type * as Scope from "effect/Scope"; import { TestClock } from "effect/testing"; -import { beforeEach, describe, expect, vi } from "vite-plus/test"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as BrowserSession from "./BrowserSession.ts"; @@ -17,7 +18,7 @@ import * as PreviewManager from "./Manager.ts"; const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } = vi.hoisted(() => ({ - createFromPath: vi.fn(() => ({ isEmpty: () => false })), + createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), fromId: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), @@ -79,6 +80,7 @@ const layer = PreviewManager.layer.pipe( Layer.provideMerge(fileSystemLayer), Layer.provideMerge(Path.layer), ); +const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); const withManager = ( use: ( @@ -237,6 +239,20 @@ describe("PreviewManager", () => { expect(artifact.path).toMatch( /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, ); + + const captureCause = new Error("capture failed"); + capturePage.mockRejectedValueOnce(captureCause); + const exit = yield* Effect.exit(manager.captureScreenshot("tab_1")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewOperationError", + operation: "captureScreenshot.capturePage", + tabId: "tab_1", + webContentsId: 42, + cause: captureCause, + }); }), ), ); @@ -302,9 +318,12 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - message: "Preview artifact path is outside the configured artifact directory.", + expect(error).toMatchObject({ + _tag: "PreviewArtifactPathOutsideDirectoryError", + artifactPath: "/tmp/t3/dev/settings.json", + artifactDirectory: "/tmp/t3/dev/browser-artifacts", }); + expect("cause" in error).toBe(false); }), ), ); @@ -324,8 +343,20 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - message: "Preview artifact path is outside the configured artifact directory.", + expect(error).toMatchObject({ + _tag: "PreviewArtifactPathOutsideDirectoryError", + artifactPath: "/tmp/t3/dev/settings.json", + artifactDirectory: "/tmp/t3/dev/browser-artifacts", + }); + expect("cause" in error).toBe(false); + + createFromPath.mockReturnValueOnce({ isEmpty: () => true }); + const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); + expect(Exit.isFailure(invalidImageExit)).toBe(true); + if (Exit.isSuccess(invalidImageExit)) return; + expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({ + _tag: "PreviewArtifactImageLoadError", + artifactPath, }); }), ), @@ -466,10 +497,174 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - name: "PreviewAutomationControlInterruptedError", + expect(error).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + operation: "click", + tabId: "tab_1", + webContentsId: 42, }); + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.name).toBe("PreviewAutomationControlInterruptedError"); + } + expect("cause" in error).toBe(false); }), ), ); + + effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () => + withManager((manager) => + Effect.gen(function* () { + const text = "ReferenceError: fallbackDetail is not defined"; + const exceptionDetails = { + text, + exception: { description: "" }, + }; + const sendCommand = vi.fn(async (method: string) => + method === "Runtime.evaluate" ? { exceptionDetails } : undefined, + ); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const exit = yield* Effect.exit( + manager.automationEvaluate("tab_1", { expression: "fallbackDetail" }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewAutomationEvaluationError", + detailKind: "exception-text", + detailLength: text.length, + cause: exceptionDetails, + }); + }), + ), + ); +}); + +describe("PreviewOperationError", () => { + it("keeps timeline detail separate from its structured message", () => { + const cause = new Error("CDP command failed with an invalid node id"); + const error = new PreviewManager.PreviewOperationError({ + operation: "click.DOM.resolveNode", + tabId: "tab_1", + webContentsId: 42, + cause, + }); + + expect(error.message).not.toContain(cause.message); + expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message); + }); +}); + +describe("Preview automation diagnostics", () => { + it("keeps browser exception detail out of structural diagnostics", () => { + const secret = "unrelated-browser-payload-secret"; + const detail = "ReferenceError: missingValue is not defined"; + const cause = { + text: "Uncaught Error", + exception: { description: detail }, + unsafePayload: secret, + }; + const error = new PreviewManager.PreviewAutomationEvaluationError({ + tabId: "tab_1", + detailKind: "exception-description", + detailLength: detail.length, + cause, + }); + + const encoded = encodePreviewManagerError(error); + const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & { + readonly cause?: unknown; + }; + + expect(error.cause).toBe(cause); + expect(encodedCause).toStrictEqual(cause); + expect(error.message).toBe("Preview JavaScript evaluation failed in tab tab_1"); + expect(error.message).not.toContain(secret); + expect(JSON.stringify(encodedDiagnostics)).not.toContain(secret); + expect("detail" in error).toBe(false); + expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).toBe(detail); + expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).not.toContain( + secret, + ); + }); + + it("retains bounded selector diagnostics without exposing selector or reason text", () => { + const selector = "role=button[name='selector-secret']"; + const reason = "Unexpected token near reason-secret"; + const cause = { invalidSelector: true as const, message: reason }; + const error = new PreviewManager.PreviewAutomationInvalidSelectorError({ + operation: "click", + tabId: "tab_1", + selectorKind: "locator", + selectorLength: selector.length, + reasonLength: reason.length, + cause, + }); + + const encoded = encodePreviewManagerError(error); + const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & { + readonly cause?: unknown; + }; + + expect(error.cause).toBe(cause); + expect(encodedCause).toStrictEqual(cause); + expect(error).toMatchObject({ + selectorKind: "locator", + selectorLength: selector.length, + reasonLength: reason.length, + }); + expect(error.detail).toEqual({ + selectorKind: "locator", + selectorLength: selector.length, + }); + expect(error.message).not.toContain("secret"); + expect(JSON.stringify(encodedDiagnostics)).not.toContain("secret"); + expect("selector" in error).toBe(false); + expect("reason" in error).toBe(false); + expect(PreviewManager.PreviewAutomationInvalidSelectorError.toTimelineMessage(error)).toBe( + reason, + ); + }); + + it("does not retain a missing target locator", () => { + const selector = "[data-token='target-secret']"; + const error = new PreviewManager.PreviewAutomationTargetNotFoundError({ + operation: "scroll", + tabId: "tab_1", + selectorKind: "selector", + selectorLength: selector.length, + }); + + expect(error.message).not.toContain(selector); + expect(JSON.stringify(error)).not.toContain(selector); + expect("locator" in error).toBe(false); + }); }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 6d25fc9b2c0..bb3e1fcef93 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -148,22 +148,58 @@ interface CdpEvaluationResult { }; } -const automationError = ( - tag: - | "PreviewAutomationExecutionError" - | "PreviewAutomationInvalidSelectorError" - | "PreviewAutomationResultTooLargeError" - | "PreviewAutomationTimeoutError" - | "PreviewAutomationControlInterruptedError", - message: string, - detail?: unknown, -): Error & { detail?: unknown } => { - const error = new Error(message) as Error & { detail?: unknown }; - error.name = tag; - if (detail !== undefined) error.detail = detail; - return error; +export const PreviewAutomationSelectorKind = Schema.Literals([ + "focused-element", + "selector", + "locator", +]); +export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type; + +export const PreviewAutomationEvaluationDetailKind = Schema.Literals([ + "exception-description", + "exception-text", + "unknown", +]); +export type PreviewAutomationEvaluationDetailKind = + typeof PreviewAutomationEvaluationDetailKind.Type; + +const previewAutomationEvaluationDetail = (exceptionDetails: unknown) => { + if (typeof exceptionDetails !== "object" || exceptionDetails === null) { + return { detailKind: "unknown" as const }; + } + const details = exceptionDetails as Record; + const exception = details["exception"]; + const description = + typeof exception === "object" && + exception !== null && + typeof (exception as Record)["description"] === "string" + ? (exception as Record)["description"] + : undefined; + if (typeof description === "string" && description.length > 0) { + return { detailKind: "exception-description" as const, detail: description }; + } + const text = details["text"]; + if (typeof text === "string" && text.length > 0) { + return { detailKind: "exception-text" as const, detail: text }; + } + return { detailKind: "unknown" as const }; }; +const previewAutomationTargetLabel = ( + selectorKind: PreviewAutomationSelectorKind, + selectorLength?: number, +) => + selectorKind === "focused-element" + ? "the focused element" + : `${selectorKind} (${selectorLength ?? 0} characters)`; + +interface PreviewOperationContext { + readonly operation: string; + readonly tabId?: string; + readonly webContentsId?: number; + readonly artifactPath?: string; +} + const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { if (typeof value !== "object" || value === null) return null; const rect = value as Record; @@ -194,6 +230,7 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; const captureAnnotationScreenshot = ( + tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, ): Effect.Effect => @@ -209,7 +246,13 @@ const captureAnnotationScreenshot = ( } : undefined, ), - catch: (cause) => new PreviewManagerError({ operation: "captureAnnotationScreenshot", cause }), + catch: (cause) => + new PreviewOperationError({ + operation: "captureAnnotationScreenshot", + tabId, + webContentsId: wc.id, + cause, + }), }).pipe( Effect.map((image) => { const size = image.getSize(); @@ -341,15 +384,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); const playwrightInstallExpression = yield* Effect.cached( - playwrightInjectedRuntimeInstallExpression().pipe( - Effect.mapError( - (cause) => - new PreviewManagerError({ - operation: "ensurePlaywrightInjected", - cause, - }), - ), - ), + playwrightInjectedRuntimeInstallExpression(), ); const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); @@ -377,16 +412,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pointerSequenceRef = yield* Ref.make(0); const recordingTabIdRef = yield* Ref.make>(Option.none()); - const fail = (operation: string, cause: unknown): PreviewManagerError => - new PreviewManagerError({ operation, cause }); - const attempt = (operation: string, evaluate: () => A) => - Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) }); - const attemptPromise = (operation: string, evaluate: () => PromiseLike) => - Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) }); + const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => + Effect.try({ + try: evaluate, + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }); + const attemptPromise = ( + errorContext: PreviewOperationContext, + evaluate: () => PromiseLike, + ) => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }); const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const currentMillis = Clock.currentTimeMillis; - const encodeJson = (operation: string, value: unknown) => - encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause))); + const encodeJson = (errorContext: PreviewOperationContext, value: unknown) => + encodeUnknownJson(value).pipe( + Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })), + ); const nextCounter = (ref: Ref.Ref) => Ref.modify(ref, (value) => [value, value + 1] as const); const replaceMap = ( @@ -432,23 +476,23 @@ 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 })); + return yield* new PreviewTabNotFoundError({ tabId }); } if (tab.webContentsId == null) { - return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError({ tabId })); + return yield* new PreviewWebviewNotInitializedError({ tabId }); } const wc = webContents.fromId(tab.webContentsId); if (!wc) { - return yield* fail( - "requireWebContents", - new PreviewWebContentsNotFoundError({ tabId, webContentsId: tab.webContentsId }), - ); + return yield* new PreviewWebContentsNotFoundError({ + tabId, + webContentsId: tab.webContentsId, + }); } return wc; }); const resolveArtifactPath = (artifactPath: string) => - attempt("resolveArtifactPath", () => { + attempt({ operation: "resolveArtifactPath", artifactPath }, () => { const resolvedPath = path.resolve(artifactPath); const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath); if ( @@ -464,10 +508,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.flatMap((resolvedPath) => resolvedPath === null ? Effect.fail( - fail( - "resolveArtifactPath", - new Error("Preview artifact path is outside the configured artifact directory."), - ), + new PreviewArtifactPathOutsideDirectoryError({ + artifactPath, + artifactDirectory: resolvedArtifactDirectory, + }), ) : Effect.succeed(resolvedPath), ), @@ -636,129 +680,137 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( wc: Electron.WebContents, ) { - return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => { - const existing = sessions.get(wc.id); - if (existing) return Effect.succeed([existing, sessions] as const); - if (wc.isDevToolsOpened()) { - return Effect.fail( - fail( - "ensureControlSession", - automationError( - "PreviewAutomationExecutionError", - "Close preview DevTools before using agent browser control.", - ), - ), - ); - } - if (wc.debugger.isAttached()) { - return Effect.fail( - fail( - "ensureControlSession", - automationError( - "PreviewAutomationExecutionError", - "Preview control cannot attach because another debugger owns this page.", - ), - ), - ); - } - const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { - const semaphore = yield* Semaphore.make(1); - const scope = yield* Scope.fork(parentScope, "sequential"); - const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* ( - method: string, - params: Record, - ) { - if (method === "Page.screencastFrame") { - const sessionId = params["sessionId"]; - if (typeof sessionId === "number") { - yield* attemptPromise("ackScreencastFrame", () => - wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), - ).pipe(Effect.ignore); - } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), - { discard: true }, - ); - } - } - yield* captureDiagnosticMessage(wc.id, method, params); - }); - const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { - runFork(handleDebuggerMessage(method, params)); - }; - yield* Scope.addFinalizer( - scope, - Effect.all( - [ - Ref.update(diagnosticsRef, (diagnostics) => - replaceMap(diagnostics, (copy) => { - copy.delete(wc.id); - }), - ), - attempt("detachControlSession", () => { - wc.debugger.off("message", onMessage); - if (wc.debugger.isAttached()) wc.debugger.detach(); - }).pipe(Effect.ignore), - ], - { discard: true }, - ), - ); - const control: BrowserControlSession = { - webContentsId: wc.id, - semaphore, - scope, - onMessage, - }; - const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { - yield* Ref.update(diagnosticsRef, (diagnostics) => - replaceMap(diagnostics, (copy) => { - copy.set(wc.id, { - consoleEntries: [], - networkEntries: [], - requests: new Map(), - }); + return yield* SynchronizedRef.modifyEffect( + controlSessionsRef, + ( + sessions, + ): Effect.Effect< + readonly [BrowserControlSession, ReadonlyMap], + PreviewManagerError + > => { + const existing = sessions.get(wc.id); + if (existing) return Effect.succeed([existing, sessions] as const); + if (wc.isDevToolsOpened()) { + return Effect.fail( + new PreviewAutomationDevToolsOpenError({ + webContentsId: wc.id, }), ); - yield* attempt("attachDebuggerListeners", () => { - wc.debugger.on("message", onMessage); - wc.debugger.attach("1.3"); - }); - yield* Effect.all( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( - (method) => - attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), + } + if (wc.debugger.isAttached()) { + return Effect.fail( + new PreviewAutomationDebuggerAttachedError({ + webContentsId: wc.id, + }), + ); + } + const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { + const semaphore = yield* Semaphore.make(1); + const scope = yield* Scope.fork(parentScope, "sequential"); + const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( + function* (method: string, params: Record) { + if (method === "Page.screencastFrame") { + const sessionId = params["sessionId"]; + if (typeof sessionId === "number") { + yield* attemptPromise( + { + operation: "ackScreencastFrame", + webContentsId: wc.id, + }, + () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + ).pipe(Effect.ignore); + } + const tabId = yield* tabIdForWebContents(wc.id); + const metadata = + typeof params["metadata"] === "object" && params["metadata"] !== null + ? (params["metadata"] as Record) + : {}; + if (tabId && typeof params["data"] === "string") { + const receivedAt = yield* currentIso; + const listeners = yield* Ref.get(recordingFrameListenersRef); + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: params["data"], + width: + typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: + typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt, + }; + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), + { discard: true }, + ); + } + } + yield* captureDiagnosticMessage(wc.id, method, params); + }, + ); + const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { + runFork(handleDebuggerMessage(method, params)); + }; + yield* Scope.addFinalizer( + scope, + Effect.all( + [ + Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(wc.id); + }), + ), + attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { + wc.debugger.off("message", onMessage); + if (wc.debugger.isAttached()) wc.debugger.detach(); + }).pipe(Effect.ignore), + ], + { discard: true }, ), - { concurrency: "unbounded", discard: true }, ); - return [ - control, - replaceMap(sessions, (copy) => { - copy.set(wc.id, control); - }), - ] as const; + const control: BrowserControlSession = { + webContentsId: wc.id, + semaphore, + scope, + onMessage, + }; + const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.set(wc.id, { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }); + }), + ); + yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { + wc.debugger.on("message", onMessage); + wc.debugger.attach("1.3"); + }); + yield* Effect.all( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( + (method) => + attemptPromise( + { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, + () => wc.debugger.sendCommand(method), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + return [ + control, + replaceMap(sessions, (copy) => { + copy.set(wc.id, control); + }), + ] as const; + }); + return yield* initialize().pipe( + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + ); }); - return yield* initialize().pipe( - Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), - ); - }); - return createControlSession(); - }); + return createControlSession(); + }, + ); }); const pushAction = (tabId: string, event: PreviewAutomationActionEvent) => @@ -808,26 +860,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function function* (method, commandParams) { const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (before !== epoch) { - return yield* fail( - action, - automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", - ), - ); + return yield* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); } - const result = yield* attemptPromise(action, () => - wc.debugger.sendCommand(method, commandParams), + const result = yield* attemptPromise( + { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, + () => wc.debugger.sendCommand(method, commandParams), ); const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (after !== epoch) { - return yield* fail( - action, - automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", - ), - ); + return yield* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); } return result; }, @@ -846,15 +895,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); } else { const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); - const underlying = isPreviewManagerError(error) ? error.cause : error; - const interrupted = - underlying instanceof Error && - underlying.name === "PreviewAutomationControlInterruptedError"; + const interrupted = isPreviewAutomationControlInterruptedError(error); + const errorMessage = isPreviewOperationError(error) + ? PreviewOperationError.toTimelineMessage(error) + : isPreviewAutomationEvaluationError(error) + ? PreviewAutomationEvaluationError.toTimelineMessage(error) + : isPreviewAutomationInvalidSelectorError(error) + ? PreviewAutomationInvalidSelectorError.toTimelineMessage(error) + : error instanceof Error + ? error.message + : String(error); yield* replaceAction(tabId, { ...actionEvent, status: interrupted ? "interrupted" : "failed", completedAt, - error: underlying instanceof Error ? underlying.message : String(underlying), + error: errorMessage, }); } const tabs = yield* SynchronizedRef.get(tabsRef); @@ -864,6 +919,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const evaluateWithDebugger = ( + tabId: string, send: SendCommand, expression: string, returnByValue: boolean, @@ -877,19 +933,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe( Effect.flatMap((rawResponse) => { const response = rawResponse as CdpEvaluationResult; - return response.exceptionDetails - ? Effect.fail( - fail( - "evaluate", - automationError( - "PreviewAutomationExecutionError", - response.exceptionDetails.exception?.description ?? - response.exceptionDetails.text ?? - "JavaScript evaluation failed.", - ), - ), - ) - : Effect.succeed(response.result?.value as A); + if (!response.exceptionDetails) { + return Effect.succeed(response.result?.value as A); + } + const detail = previewAutomationEvaluationDetail(response.exceptionDetails); + return Effect.fail( + new PreviewAutomationEvaluationError({ + tabId, + detailKind: detail.detailKind, + detailLength: detail.detail?.length ?? 0, + cause: response.exceptionDetails, + }), + ); }), ); @@ -898,17 +953,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function readonly locator?: string | undefined; }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null); + const automationSelectorDiagnostics = (input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): { + readonly selectorKind: PreviewAutomationSelectorKind; + readonly selectorLength?: number; + } => { + if (input.locator !== undefined) { + return { selectorKind: "locator", selectorLength: input.locator.length }; + } + if (input.selector !== undefined) { + return { selectorKind: "selector", selectorLength: input.selector.length }; + } + return { selectorKind: "focused-element" }; + }; + const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* ( + tabId: string, send: SendCommand, ) { const installed = yield* evaluateWithDebugger( + tabId, send, "Boolean(globalThis.__t3PlaywrightInjected)", true, ); if (installed) return; - const expression = yield* playwrightInstallExpression; - yield* evaluateWithDebugger(send, expression, true); + const expression = yield* playwrightInstallExpression.pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "ensurePlaywrightInjected", + tabId, + cause, + }), + ), + ); + yield* evaluateWithDebugger(tabId, send, expression, true); }); const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( @@ -1060,7 +1142,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; yield* Scope.addFinalizer( scope, - attempt("detachListeners", () => { + attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { wc.off("did-navigate", sync); wc.off("did-navigate-in-page", sync); wc.off("page-title-updated", sync); @@ -1072,7 +1154,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { - yield* attempt("attachListeners", () => { + yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { wc.on("did-navigate", sync); wc.on("did-navigate-in-page", sync); wc.on("page-title-updated", sync); @@ -1081,7 +1163,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.setWindowOpenHandler(({ url }) => { - runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); + runFork( + attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => + wc.loadURL(url), + ).pipe(Effect.ignore), + ); return { action: "deny" }; }); wc.on("before-input-event", beforeInput); @@ -1162,7 +1248,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) { - return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId })); + return yield* new PreviewTabNotFoundError({ tabId }); } const wc = webContents.fromId(webContentsId); const mainWindow = yield* Ref.get(mainWindowRef); @@ -1171,15 +1257,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.getType() !== "webview" || (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) ) { - return yield* fail( - "registerWebview", - new PreviewWebContentsNotFoundError({ tabId, webContentsId }), - ); + return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); } const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { - yield* attempt("registerWebview.sendTheme", () => + yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); return; @@ -1225,16 +1308,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); if (Option.isNone(registration)) { - return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId })); + return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; yield* emit(tabId, registered); if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { - yield* attempt("registerWebview.restoreZoom", () => + yield* attempt({ operation: "registerWebview.restoreZoom", tabId, webContentsId }, () => wc.setZoomFactor(registered.zoomFactor), ).pipe(Effect.ignore); } - yield* attempt("registerWebview.sendTheme", () => + yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus; @@ -1245,15 +1328,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.getURL() !== pendingUrl ) { runFork( - attemptPromise("registerWebview.loadPendingUrl", () => wc.loadURL(pendingUrl)).pipe( - Effect.ignore, - ), + attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () => + wc.loadURL(pendingUrl), + ).pipe(Effect.ignore), ); } }); const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { - const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl)); + const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => + normalizePreviewUrl(rawUrl), + ); const updatedAt = yield* currentIso; const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); @@ -1294,10 +1379,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } if (wc.getURL() === url) { - yield* attempt("navigate.reload", () => wc.reload()); + yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => + wc.reload(), + ); return; } - yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url)); + yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => + wc.loadURL(url), + ); }); const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( @@ -1306,7 +1395,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function use: (wc: Electron.WebContents) => void, ) { const wc = yield* requireWebContents(tabId); - yield* attempt(operation, () => use(wc)); + yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); }); const goBack = (tabId: string) => @@ -1324,11 +1413,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); if (wc.isDevToolsOpened()) { - yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus()); + yield* attempt({ operation: "openDevTools.focus", tabId, webContentsId: wc.id }, () => + wc.devToolsWebContents?.focus(), + ); return; } yield* detachControlSession(wc.id); - yield* attempt("openDevTools", () => { + yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { wc.once("devtools-closed", () => { if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); }); @@ -1348,9 +1439,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = webContents.fromId(tab.webContentsId); return !wc || wc.isDestroyed() ? Effect.void - : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe( - Effect.ignore, - ); + : attempt( + { + operation: "setAnnotationTheme", + tabId: tab.tabId, + webContentsId: tab.webContentsId, + }, + () => wc.send(ANNOTATION_THEME_CHANNEL, theme), + ).pipe(Effect.ignore); }, { discard: true }, ); @@ -1363,7 +1459,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* Effect.callback( (resume) => { const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { - yield* attempt("pickElement.cleanup", () => { + yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); @@ -1392,9 +1488,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (activeTab?.webContentsId != null) { const activeWc = webContents.fromId(activeTab.webContentsId); if (activeWc && !activeWc.isDestroyed()) { - yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe( - Effect.ignore, - ); + yield* attempt( + { + operation: "cancelPickElement", + tabId, + webContentsId: activeWc.id, + }, + () => activeWc.send(CANCEL_PICK_CHANNEL), + ).pipe(Effect.ignore); } } resume(Effect.succeed(null)); @@ -1408,15 +1509,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const cropRect = normalizeCaptureRect(args[1]); runFork( - captureAnnotationScreenshot(wc, cropRect).pipe( + captureAnnotationScreenshot(tabId, wc, cropRect).pipe( Effect.matchEffect({ onFailure: () => Effect.sync(() => settle(payload)), onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), }), Effect.ensuring( - attempt("pickElement.captureComplete", () => { - if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); - }).pipe(Effect.ignore), + attempt( + { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, + () => { + if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); + }, + ).pipe(Effect.ignore), ), ), ); @@ -1431,7 +1535,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { - yield* attempt("pickElement.register", () => { + yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); wc.once("destroyed", onDestroyed); wc.once("did-start-navigation", onNavigated); @@ -1468,7 +1572,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (tab.webContentsId != null) { const wc = webContents.fromId(tab.webContentsId); if (wc && !wc.isDestroyed()) { - yield* attempt("applyZoom", () => wc.setZoomFactor(next)); + yield* attempt({ operation: "applyZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(next), + ); } } yield* update(tabId, { zoomFactor: next }); @@ -1481,17 +1587,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - attemptPromise("captureScreenshot.capturePage", () => wc.capturePage()), + attemptPromise( + { + operation: "captureScreenshot.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ), ]); const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`); const data = image.toPNG(); - yield* fileSystem - .makeDirectory(resolvedArtifactDirectory, { recursive: true }) - .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause))); - yield* fileSystem - .writeFile(artifactPath, data) - .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause))); + yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "captureScreenshot.makeDirectory", + tabId, + webContentsId: wc.id, + artifactPath, + cause, + }), + ), + ); + yield* fileSystem.writeFile(artifactPath, data).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "captureScreenshot.writeFile", + tabId, + webContentsId: wc.id, + artifactPath, + cause, + }), + ), + ); return { id, tabId, @@ -1518,10 +1649,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { const recordingTabId = yield* Ref.get(recordingTabIdRef); if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { - return yield* fail( - "startRecording", - new Error("Only one browser recording can be active per window."), - ); + return yield* new PreviewRecordingAlreadyActiveError({ + requestedTabId: tabId, + activeTabId: recordingTabId.value, + }); } const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "recording.start", startScreencast); @@ -1547,12 +1678,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const id = `browser-recording-${millis.toString(36)}`; const extension = mimeType.includes("mp4") ? "mp4" : "webm"; const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); - yield* fileSystem - .makeDirectory(resolvedArtifactDirectory, { recursive: true }) - .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause))); - yield* fileSystem - .writeFile(artifactPath, data) - .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause))); + yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "saveRecording.makeDirectory", + tabId, + artifactPath, + cause, + }), + ), + ); + yield* fileSystem.writeFile(artifactPath, data).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "saveRecording.writeFile", + tabId, + artifactPath, + cause, + }), + ), + ); return { id, tabId, @@ -1609,6 +1756,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function visibleText: string; interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; }>( + tabId, send, `(() => { const selectorFor = (element) => { @@ -1665,7 +1813,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ send("Accessibility.getFullAXTree"), - attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), + attemptPromise( + { + operation: "automationSnapshot.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ), Ref.get(diagnosticsRef), Ref.get(actionTimelineRef), ]); @@ -1702,6 +1857,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* ( + tabId: string, send: SendCommand, input: PreviewAutomationClickInput, ) { @@ -1709,11 +1865,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return { x: input.x!, y: input.y! }; } const locator = automationLocator(input)!; - yield* ensurePlaywrightInjected(send); - const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator); + yield* ensurePlaywrightInjected(tabId, send); + const locatorJson = yield* encodeJson( + { operation: "automationClick.encodeLocator", tabId }, + locator, + ); const point = yield* evaluateWithDebugger< { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1734,21 +1894,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in point) { - return yield* fail( - "automationClick", - automationError("PreviewAutomationInvalidSelectorError", point.message, { - selector: locator, - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: point.message.length, + cause: point, + }); } if ("notFound" in point) { - return yield* fail( - "automationClick", - automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + }); } return point; }); @@ -1773,20 +1932,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], { concurrency: 2, discard: true }, ); - const point = yield* resolveClickPoint(send, input); + const point = yield* resolveClickPoint(tabId, send, input); const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( + tabId, send, "({ width: window.innerWidth, height: window.innerHeight })", true, ); if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { - return yield* fail( - "automationClick", - automationError( - "PreviewAutomationExecutionError", - `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, - ), - ); + return yield* new PreviewAutomationCoordinatesOutsideViewportError({ + tabId, + x: point.x, + y: point.y, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }); } const moveSequence = yield* nextCounter(pointerSequenceRef); const moveCreatedAt = yield* currentIso; @@ -1834,15 +1994,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( + tabId: string, send: SendCommand, input: PreviewAutomationTypeInput, ) { const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); - const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null; + if (locator) yield* ensurePlaywrightInjected(tabId, send); + const locatorJson = locator + ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) + : null; const result = yield* evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1862,23 +2026,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationType", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "type", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if ("notFound" in result) { - return yield* fail( - "automationType", - automationError( - "PreviewAutomationExecutionError", - locator - ? `No element matches locator ${locator}.` - : "No element is focused in the preview.", - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "type", + tabId, + ...automationSelectorDiagnostics(input), + }); } }); @@ -1888,10 +2049,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function send: SendCommand, ) { yield* send("Runtime.enable"); - yield* focusAutomationTarget(send, input); + yield* focusAutomationTarget(tabId, send, input); yield* send("Input.insertText", { text: input.text }); - const textJson = yield* encodeJson("automationType.encodeText", input.text); + const textJson = yield* encodeJson( + { operation: "automationType.encodeText", tabId }, + input.text, + ); yield* evaluateWithDebugger( + tabId, send, `(() => { const element = document.activeElement; @@ -1959,13 +2124,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { yield* send("Runtime.enable"); const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); + if (locator) yield* ensurePlaywrightInjected(tabId, send); const locatorJson = locator - ? yield* encodeJson("automationScroll.encodeLocator", locator) + ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator) : null; const result = yield* evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1980,21 +2146,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationScroll", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "scroll", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if ("notFound" in result) { - return yield* fail( - "automationScroll", - automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "scroll", + tabId, + ...automationSelectorDiagnostics(input), + }); } }); @@ -2009,24 +2174,26 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( - function* (input: PreviewAutomationEvaluateInput, send: SendCommand) { + function* (tabId: string, input: PreviewAutomationEvaluateInput, send: SendCommand) { yield* send("Runtime.enable"); const value = yield* evaluateWithDebugger( + tabId, send, input.expression, input.returnByValue ?? true, input.awaitPromise ?? true, ); - const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); - if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { - return yield* fail( - "automationEvaluate", - automationError( - "PreviewAutomationResultTooLargeError", - `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, - { maximumBytes: MAX_EVALUATION_BYTES }, - ), - ); + const serialized = yield* encodeJson( + { operation: "automationEvaluate.encodeResult", tabId }, + value, + ); + const actualBytes = Buffer.byteLength(serialized, "utf8"); + if (actualBytes > MAX_EVALUATION_BYTES) { + return yield* new PreviewAutomationResultTooLargeError({ + tabId, + actualBytes, + maximumBytes: MAX_EVALUATION_BYTES, + }); } return value; }, @@ -2038,23 +2205,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); return yield* withControlSession(tabId, wc, "evaluate", (send) => - performAutomationEvaluate(input, send), + performAutomationEvaluate(tabId, input, send), ); }); const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( + tabId: string, input: PreviewAutomationWaitForInput, send: SendCommand, ) { const timeoutMs = input.timeoutMs ?? 15_000; yield* send("Runtime.enable"); const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); + if (locator) yield* ensurePlaywrightInjected(tabId, send); const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ - locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), - input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null), + locator + ? encodeJson({ operation: "automationWaitFor.encodeLocator", tabId }, locator) + : Effect.succeed(null), + input.text + ? encodeJson({ operation: "automationWaitFor.encodeText", tabId }, input.text) + : Effect.succeed(null), input.urlIncludes - ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) + ? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes) : Effect.succeed(null), ]); const deadline = (yield* currentMillis) + timeoutMs; @@ -2062,6 +2234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const result = yield* evaluateWithDebugger< { matched: boolean } | { invalidSelector: true; message: string } >( + tabId, send, `(() => { try { @@ -2080,23 +2253,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationWaitFor", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "waitFor", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if (result.matched) return; yield* Effect.sleep(100); } - return yield* fail( - "automationWaitFor", - automationError( - "PreviewAutomationTimeoutError", - `Preview condition did not match within ${timeoutMs}ms.`, - ), - ); + return yield* new PreviewAutomationTimeoutError({ + tabId, + timeoutMs, + }); }); const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( @@ -2105,7 +2276,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "waitFor", (send) => - performAutomationWaitFor(input, send), + performAutomationWaitFor(tabId, input, send), ); }); @@ -2113,23 +2284,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function artifactPath: string, ) { const resolvedPath = yield* resolveArtifactPath(artifactPath); - yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath)); + yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () => + shell.showItemInFolder(resolvedPath), + ); }); const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* ( artifactPath: string, ) { const resolvedPath = yield* resolveArtifactPath(artifactPath); - const image = yield* attempt("copyArtifactToClipboard.load", () => - nativeImage.createFromPath(resolvedPath), + const image = yield* attempt( + { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath }, + () => nativeImage.createFromPath(resolvedPath), ); if (image.isEmpty()) { - return yield* fail( - "copyArtifactToClipboard", - new Error("Preview artifact could not be loaded as an image."), - ); + return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath }); } - yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image)); + yield* attempt({ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () => + clipboard.writeImage(image), + ); }); const subscribe = ( @@ -2228,19 +2401,234 @@ export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass

()( - "PreviewManagerError", +export class PreviewOperationError extends Schema.TaggedErrorClass()( + "PreviewOperationError", { operation: Schema.String, + tabId: Schema.optional(Schema.String), + webContentsId: Schema.optional(Schema.Number), + artifactPath: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { + static toTimelineMessage(error: PreviewOperationError): string { + return error.cause instanceof Error ? error.cause.message : String(error.cause); + } + override get message(): string { - return `Desktop preview operation failed: ${this.operation}`; + const context = [ + this.tabId === undefined ? undefined : `tab ${this.tabId}`, + this.webContentsId === undefined ? undefined : `WebContents ${this.webContentsId}`, + this.artifactPath === undefined ? undefined : `artifact ${this.artifactPath}`, + ].filter((value): value is string => value !== undefined); + return `Desktop preview operation failed: ${this.operation}${context.length === 0 ? "" : ` (${context.join(", ")})`}`; } } -const isPreviewManagerError = Schema.is(PreviewManagerError); +export const isPreviewOperationError = Schema.is(PreviewOperationError); + +export class PreviewArtifactPathOutsideDirectoryError extends Schema.TaggedErrorClass()( + "PreviewArtifactPathOutsideDirectoryError", + { + artifactPath: Schema.String, + artifactDirectory: Schema.String, + }, +) { + override get message(): string { + return `Preview artifact path ${this.artifactPath} is outside ${this.artifactDirectory}`; + } +} + +export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass()( + "PreviewArtifactImageLoadError", + { artifactPath: Schema.String }, +) { + override get message(): string { + return `Preview artifact could not be loaded as an image: ${this.artifactPath}`; + } +} + +export class PreviewRecordingAlreadyActiveError extends Schema.TaggedErrorClass()( + "PreviewRecordingAlreadyActiveError", + { + requestedTabId: Schema.String, + activeTabId: Schema.String, + }, +) { + override get message(): string { + return `Cannot record preview tab ${this.requestedTabId} while tab ${this.activeTabId} is already recording`; + } +} + +export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass()( + "PreviewAutomationDevToolsOpenError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return `Close preview DevTools before using agent browser control for WebContents ${this.webContentsId}`; + } +} + +export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedErrorClass()( + "PreviewAutomationDebuggerAttachedError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return `Preview control cannot attach to WebContents ${this.webContentsId} because another debugger owns it`; + } +} + +export class PreviewAutomationEvaluationError extends Schema.TaggedErrorClass()( + "PreviewAutomationEvaluationError", + { + tabId: Schema.String, + detailKind: PreviewAutomationEvaluationDetailKind, + detailLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + static toTimelineMessage(error: PreviewAutomationEvaluationError): string { + return previewAutomationEvaluationDetail(error.cause).detail ?? error.message; + } + + override get message(): string { + return `Preview JavaScript evaluation failed in tab ${this.tabId}`; + } +} + +export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetNotFoundError", + { + operation: Schema.String, + tabId: Schema.String, + selectorKind: PreviewAutomationSelectorKind, + selectorLength: Schema.optionalKey(Schema.Number), + }, +) { + override get message(): string { + const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); + return `Preview automation ${this.operation} could not find ${target} in tab ${this.tabId}`; + } +} + +export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass()( + "PreviewAutomationCoordinatesOutsideViewportError", + { + tabId: Schema.String, + x: Schema.Number, + y: Schema.Number, + viewportWidth: Schema.Number, + viewportHeight: Schema.Number, + }, +) { + override get message(): string { + return `Click coordinates (${this.x}, ${this.y}) are outside the ${this.viewportWidth}x${this.viewportHeight} preview viewport for tab ${this.tabId}`; + } +} + +export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass()( + "PreviewAutomationInvalidSelectorError", + { + operation: Schema.String, + tabId: Schema.String, + selectorKind: PreviewAutomationSelectorKind, + selectorLength: Schema.optionalKey(Schema.Number), + reasonLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + static toTimelineMessage(error: PreviewAutomationInvalidSelectorError): string { + if (typeof error.cause !== "object" || error.cause === null) return error.message; + const reason = (error.cause as Record)["message"]; + return typeof reason === "string" && reason.length > 0 ? reason : error.message; + } + + get detail(): { + readonly selectorKind: PreviewAutomationSelectorKind; + readonly selectorLength?: number; + } { + return { + selectorKind: this.selectorKind, + ...(this.selectorLength === undefined ? {} : { selectorLength: this.selectorLength }), + }; + } + + override get message(): string { + const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); + return `Preview automation ${this.operation} rejected ${target} in tab ${this.tabId}`; + } +} + +export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()( + "PreviewAutomationResultTooLargeError", + { + tabId: Schema.String, + actualBytes: Schema.Number, + maximumBytes: Schema.Number, + }, +) { + get detail(): { readonly maximumBytes: number } { + return { maximumBytes: this.maximumBytes }; + } + + override get message(): string { + return `Preview evaluation result in tab ${this.tabId} was ${this.actualBytes} bytes; maximum is ${this.maximumBytes} bytes`; + } +} + +export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationTimeoutError", + { + tabId: Schema.String, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Preview condition did not match within ${this.timeoutMs}ms in tab ${this.tabId}`; + } +} + +export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass()( + "PreviewAutomationControlInterruptedError", + { + operation: Schema.String, + tabId: Schema.String, + webContentsId: Schema.Number, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} was interrupted by human input in tab ${this.tabId}`; + } +} + +export const PreviewManagerError = Schema.Union([ + PreviewTabNotFoundError, + PreviewWebContentsNotFoundError, + PreviewWebviewNotInitializedError, + PreviewOperationError, + PreviewArtifactPathOutsideDirectoryError, + PreviewArtifactImageLoadError, + PreviewRecordingAlreadyActiveError, + PreviewAutomationDevToolsOpenError, + PreviewAutomationDebuggerAttachedError, + PreviewAutomationEvaluationError, + PreviewAutomationTargetNotFoundError, + PreviewAutomationCoordinatesOutsideViewportError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationResultTooLargeError, + PreviewAutomationTimeoutError, + PreviewAutomationControlInterruptedError, +]); +export type PreviewManagerError = typeof PreviewManagerError.Type; + +export const isPreviewManagerError = Schema.is(PreviewManagerError); +export const isPreviewAutomationControlInterruptedError = Schema.is( + PreviewAutomationControlInterruptedError, +); +export const isPreviewAutomationEvaluationError = Schema.is(PreviewAutomationEvaluationError); +export const isPreviewAutomationInvalidSelectorError = Schema.is( + PreviewAutomationInvalidSelectorError, +); export class PreviewManager extends Context.Service< PreviewManager, @@ -2329,16 +2717,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; const operations = yield* makeNativeOperations(environment.browserArtifactsDir); - const browserSessionEffect = ( - operation: string, - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); return PreviewManager.of({ setMainWindow: operations.setMainWindow, getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { - return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); + return yield* browserSession + .getSession(scope) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), + ), + ); }), isBrowserPartition: browserSession.isPartition, createTab: operations.createTab, @@ -2354,13 +2743,29 @@ export const make = Effect.gen(function* PreviewManagerMake() { hardReload: operations.hardReload, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { - yield* browserSessionEffect("clearCookies", browserSession.clearCookies()); + yield* browserSession + .clearCookies() + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), + ), + ); }), clearCache: Effect.fn("PreviewManager.clearCache")(function* () { - yield* browserSessionEffect("clearCache", browserSession.clearCache()); + yield* browserSession + .clearCache() + .pipe( + Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), + ); }), getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { - return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope)); + return yield* browserSession + .getPartition(scope) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), + ), + ); }), setAnnotationTheme: operations.setAnnotationTheme, pickElement: operations.pickElement, diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts index 33915dba0be..cd7fee1e3c7 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -3,10 +3,14 @@ import * as Effect from "effect/Effect"; import { describe, expect } from "vite-plus/test"; import { + extractPlaywrightInjectedRuntimeSource, playwrightInjectedRuntimeInstallExpression, playwrightInjectedRuntimeSource, } from "./PlaywrightInjectedRuntime.ts"; +const bundleWithSourceLiteral = (literal: string): string => + `const source3 = ${literal};\n }\n});`; + describe("playwright injected runtime", () => { effectIt.effect("extracts the pinned runtime from playwright-core", () => Effect.gen(function* () { @@ -23,4 +27,54 @@ describe("playwright injected runtime", () => { expect(expression).toContain('testIdAttributeName":"data-testid'); }), ); + + effectIt.effect("reports a missing source marker without an artificial cause", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource("const source = 'missing';", "/tmp/coreBundle.js"), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceMarkerNotFoundError", + bundlePath: "/tmp/coreBundle.js", + marker: "source3 = ", + }); + expect("cause" in error).toBe(false); + }), + ); + + effectIt.effect("keeps source validation metadata cause-free", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource( + bundleWithSourceLiteral('"short"'), + "/tmp/coreBundle.js", + ), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceValidationError", + bundlePath: "/tmp/coreBundle.js", + actualType: "string", + actualLength: 5, + minimumLength: 100_000, + }); + expect("cause" in error).toBe(false); + }), + ); + + effectIt.effect("preserves the source evaluation cause", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource(bundleWithSourceLiteral("("), "/tmp/coreBundle.js"), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceEvaluationError", + bundlePath: "/tmp/coreBundle.js", + timeoutMs: 1_000, + cause: expect.objectContaining({ name: "SyntaxError" }), + }); + }), + ); }); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts index e940ce55906..ff1531f08f3 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -4,69 +4,180 @@ 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 = NodeModule.createRequire(import.meta.url); const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const PLAYWRIGHT_PACKAGE_SPECIFIER = "playwright-core/package.json"; +const PLAYWRIGHT_SOURCE_MARKER = "source3 = "; +const PLAYWRIGHT_SOURCE_TERMINATOR = ";\n }\n});"; +const PLAYWRIGHT_SOURCE_MINIMUM_LENGTH = 100_000; +const PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS = 1_000; +const PLAYWRIGHT_SDK_LANGUAGE = "javascript"; +const PLAYWRIGHT_BROWSER_NAME = "chromium"; -export class PlaywrightInjectedRuntimeError extends Data.TaggedError( - "PlaywrightInjectedRuntimeError", -)<{ - readonly operation: string; - readonly cause: unknown; -}> { - override get message() { - return `Playwright injected runtime operation failed: ${this.operation}`; +export class PlaywrightPackageResolveError extends Schema.TaggedErrorClass()( + "PlaywrightPackageResolveError", + { + specifier: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve Playwright package: ${this.specifier}`; + } +} + +export class PlaywrightCoreBundleReadError extends Schema.TaggedErrorClass()( + "PlaywrightCoreBundleReadError", + { + bundlePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read Playwright core bundle: ${this.bundlePath}`; + } +} + +export class PlaywrightSourceMarkerNotFoundError extends Schema.TaggedErrorClass()( + "PlaywrightSourceMarkerNotFoundError", + { + bundlePath: Schema.String, + marker: Schema.String, + }, +) { + override get message(): string { + return `Playwright injected runtime marker ${JSON.stringify(this.marker)} was not found in ${this.bundlePath}`; + } +} + +export class PlaywrightSourceTerminatorNotFoundError extends Schema.TaggedErrorClass()( + "PlaywrightSourceTerminatorNotFoundError", + { + bundlePath: Schema.String, + terminator: Schema.String, + }, +) { + override get message(): string { + return `Playwright injected runtime terminator ${JSON.stringify(this.terminator)} was not found in ${this.bundlePath}`; + } +} + +export class PlaywrightSourceEvaluationError extends Schema.TaggedErrorClass()( + "PlaywrightSourceEvaluationError", + { + bundlePath: Schema.String, + timeoutMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to evaluate the Playwright injected runtime literal from ${this.bundlePath} within ${this.timeoutMs}ms`; } } -const fail = (operation: string, cause: unknown) => - new PlaywrightInjectedRuntimeError({ operation, cause }); +export class PlaywrightSourceValidationError extends Schema.TaggedErrorClass()( + "PlaywrightSourceValidationError", + { + bundlePath: Schema.String, + actualType: Schema.String, + actualLength: Schema.NullOr(Schema.Number), + minimumLength: Schema.Number, + }, +) { + override get message(): string { + const actual = + this.actualLength === null + ? this.actualType + : `${this.actualType} with ${this.actualLength} characters`; + return `Playwright injected runtime from ${this.bundlePath} was ${actual}; expected a string with at least ${this.minimumLength} characters`; + } +} + +export class PlaywrightOptionsEncodeError extends Schema.TaggedErrorClass()( + "PlaywrightOptionsEncodeError", + { + sdkLanguage: Schema.String, + browserName: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode ${this.browserName} Playwright injected runtime options for ${this.sdkLanguage}`; + } +} + +export const PlaywrightInjectedRuntimeError = Schema.Union([ + PlaywrightPackageResolveError, + PlaywrightCoreBundleReadError, + PlaywrightSourceMarkerNotFoundError, + PlaywrightSourceTerminatorNotFoundError, + PlaywrightSourceEvaluationError, + PlaywrightSourceValidationError, + PlaywrightOptionsEncodeError, +]); +export type PlaywrightInjectedRuntimeError = typeof PlaywrightInjectedRuntimeError.Type; + +export const extractPlaywrightInjectedRuntimeSource = Effect.fn( + "PlaywrightInjectedRuntime.extractSource", +)(function* (coreBundle: string, bundlePath: string) { + const start = coreBundle.indexOf(PLAYWRIGHT_SOURCE_MARKER); + if (start < 0) { + return yield* new PlaywrightSourceMarkerNotFoundError({ + bundlePath, + marker: PLAYWRIGHT_SOURCE_MARKER, + }); + } + const literalStart = start + PLAYWRIGHT_SOURCE_MARKER.length; + const literalEnd = coreBundle.indexOf(PLAYWRIGHT_SOURCE_TERMINATOR, literalStart); + if (literalEnd < 0) { + return yield* new PlaywrightSourceTerminatorNotFoundError({ + bundlePath, + terminator: PLAYWRIGHT_SOURCE_TERMINATOR, + }); + } + const literal = coreBundle.slice(literalStart, literalEnd); + const source = yield* Effect.try({ + try: () => + NodeVM.runInNewContext(literal, Object.create(null), { + timeout: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS, + }), + catch: (cause) => + new PlaywrightSourceEvaluationError({ + bundlePath, + timeoutMs: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS, + cause, + }), + }); + if (typeof source !== "string" || source.length < PLAYWRIGHT_SOURCE_MINIMUM_LENGTH) { + return yield* new PlaywrightSourceValidationError({ + bundlePath, + actualType: typeof source, + actualLength: typeof source === "string" ? source.length : null, + minimumLength: PLAYWRIGHT_SOURCE_MINIMUM_LENGTH, + }); + } + return source; +}); export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")( function* () { const packageJsonPath = yield* Effect.try({ - try: () => require.resolve("playwright-core/package.json"), - catch: (cause) => fail("resolvePackage", cause), + try: () => require.resolve(PLAYWRIGHT_PACKAGE_SPECIFIER), + catch: (cause) => + new PlaywrightPackageResolveError({ + specifier: PLAYWRIGHT_PACKAGE_SPECIFIER, + cause, + }), }); + const bundlePath = NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js"); const coreBundle = yield* Effect.tryPromise({ - try: () => - NodeFSP.readFile( - NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js"), - "utf8", - ), - catch: (cause) => fail("readCoreBundle", cause), - }); - const marker = "source3 = "; - const start = coreBundle.indexOf(marker); - if (start < 0) { - return yield* fail( - "findSourceMarker", - new Error("Playwright injected runtime marker was not found."), - ); - } - const literalStart = start + marker.length; - const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); - if (literalEnd < 0) { - return yield* fail( - "findSourceTerminator", - new Error("Playwright injected runtime terminator was not found."), - ); - } - const literal = coreBundle.slice(literalStart, literalEnd); - const source = yield* Effect.try({ - try: () => NodeVM.runInNewContext(literal, Object.create(null), { timeout: 1_000 }), - catch: (cause) => fail("evaluateSourceLiteral", cause), + try: () => NodeFSP.readFile(bundlePath, "utf8"), + catch: (cause) => new PlaywrightCoreBundleReadError({ bundlePath, cause }), }); - if (typeof source !== "string" || source.length < 100_000) { - return yield* fail( - "validateSource", - new Error("Playwright injected runtime extraction returned invalid source."), - ); - } - return source; + return yield* extractPlaywrightInjectedRuntimeSource(coreBundle, bundlePath); }, ); @@ -76,14 +187,23 @@ export const playwrightInjectedRuntimeInstallExpression = Effect.fn( const source = yield* playwrightInjectedRuntimeSource(); const options = yield* encodeUnknownJson({ isUnderTest: false, - sdkLanguage: "javascript", + sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE, testIdAttributeName: "data-testid", stableRafCount: 1, - browserName: "chromium", + browserName: PLAYWRIGHT_BROWSER_NAME, shouldPrependErrorPrefix: false, isUtilityWorld: false, customEngines: [], - }).pipe(Effect.mapError((cause) => fail("encodeOptions", cause))); + }).pipe( + Effect.mapError( + (cause) => + new PlaywrightOptionsEncodeError({ + sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE, + browserName: PLAYWRIGHT_BROWSER_NAME, + cause, + }), + ), + ); return `(() => { if (globalThis.__t3PlaywrightInjected) return true; const module = { exports: {} }; From d2e38c53735718cee17c7aeb58f6c71be24bf435 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:56:52 -0700 Subject: [PATCH 144/257] [codex] Structure Tailscale command failures (#3257) Co-authored-by: codex --- packages/tailscale/src/tailscale.test.ts | 102 +++++++- packages/tailscale/src/tailscale.ts | 286 +++++++++++++---------- 2 files changed, 256 insertions(+), 132 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index f1d47ad9d21..853bb1f81c2 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -16,6 +18,10 @@ import { parseTailscaleStatus, readTailscaleStatus, TAILSCALE_STATUS_TIMEOUT, + TailscaleCommandExitError, + TailscaleCommandSpawnError, + TailscaleCommandTimeoutError, + TailscaleStatusParseError, } from "./tailscale.ts"; const encoder = new TextEncoder(); @@ -100,6 +106,17 @@ describe("tailscale", () => { }), ); + it.effect("preserves status decoding failures without exposing cause text", () => + Effect.gen(function* () { + const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); + + assert.instanceOf(error, TailscaleStatusParseError); + assert.equal(error.message, "Failed to decode tailscale status JSON."); + assert.isDefined(error.cause); + assert.notInclude(error.message, String(error.cause)); + }), + ); + it.effect("builds clean HTTPS base URLs", () => Effect.sync(() => { assert.equal( @@ -131,6 +148,55 @@ describe("tailscale", () => { }); }); + it.effect("preserves tailscale spawn failures as causes", () => { + const systemCause = new Error("private executable lookup detail"); + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: systemCause, + }); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandSpawnError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to spawn tailscale status."); + assert.notInclude(error.message, systemCause.message); + }); + }); + + it.effect("keeps nonzero exit diagnostics structured", () => { + const layer = mockSpawnerLayer(() => ({ + code: 7, + stderr: "not logged in tskey-auth-secret-token-value", + })); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.equal(error.exitCode, 7); + assert.equal(error.stdoutLength, 0); + assert.equal(error.stderrLength, 43); + assert.notProperty(error, "command"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + assert.equal(error.message, "tailscale status exited with code 7."); + }); + }); + it.effect("times out tailscale status through TestClock", () => { const layer = Layer.merge( TestClock.layer(), @@ -146,11 +212,13 @@ describe("tailscale", () => { 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); + assert.instanceOf(error, TailscaleCommandTimeoutError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.equal(error.timeoutMs, 1_500); + assert.isTrue(Cause.isTimeoutError(error.cause)); + assert.equal(error.message, "tailscale status timed out after 1500ms."); }).pipe(Effect.provide(layer)); }); @@ -164,6 +232,30 @@ describe("tailscale", () => { return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer)); }); + it.effect("retains tailscale serve exit diagnostics", () => { + const layer = mockSpawnerLayer(() => ({ + code: 1, + stderr: "serve permission denied tskey-auth-secret-token-value", + })); + + return Effect.gen(function* () { + const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "serve"); + assert.equal(error.argumentCount, 4); + assert.equal(error.exitCode, 1); + assert.equal(error.stderrLength, 53); + assert.notProperty(error, "command"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + }); + }); + it.effect("disables tailscale serve through the process spawner service", () => { const commands: { readonly command: string; diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index f468dec7294..27761490af0 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,5 +1,4 @@ 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"; @@ -15,23 +14,82 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. -const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => +const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => platform === "win32" ? "tailscale.exe" : "tailscale"; -export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ - readonly command: readonly string[]; - readonly message: string; - readonly exitCode: number | null; - readonly stderr: string; -}> {} +const TailscaleCommandContext = { + executable: Schema.Literals(["tailscale", "tailscale.exe"]), + subcommand: Schema.Literals(["status", "serve"]), + argumentCount: Schema.Number, +}; + +export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( + "TailscaleCommandSpawnError", + { + ...TailscaleCommandContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn tailscale ${this.subcommand}.`; + } +} + +export class TailscaleCommandOutputError extends Schema.TaggedErrorClass()( + "TailscaleCommandOutputError", + { + ...TailscaleCommandContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read output from tailscale ${this.subcommand}.`; + } +} + +export class TailscaleCommandExitError extends Schema.TaggedErrorClass()( + "TailscaleCommandExitError", + { + ...TailscaleCommandContext, + exitCode: Schema.Number, + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `tailscale ${this.subcommand} exited with code ${this.exitCode}.`; + } +} -export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{ - readonly cause: unknown; -}> {} +export class TailscaleCommandTimeoutError extends Schema.TaggedErrorClass()( + "TailscaleCommandTimeoutError", + { + ...TailscaleCommandContext, + timeoutMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `tailscale ${this.subcommand} timed out after ${this.timeoutMs}ms.`; + } +} -export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{ - readonly reason: string; -}> {} +export const TailscaleCommandError = Schema.Union([ + TailscaleCommandSpawnError, + TailscaleCommandOutputError, + TailscaleCommandExitError, + TailscaleCommandTimeoutError, +]); +export type TailscaleCommandError = typeof TailscaleCommandError.Type; + +export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( + "TailscaleStatusParseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to decode tailscale status JSON."; + } +} const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), @@ -61,19 +119,6 @@ const collectStdout = (stream: Stream.Stream): Effect.Effect - new TailscaleCommandError({ - command: ["tailscale", ...args], - message, - exitCode, - stderr, - }); - const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { @@ -139,55 +184,52 @@ export const readTailscaleStatus = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const child = yield* spawner - .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) - .pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : "Failed to spawn tailscale status.", - null, - ), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStdout(child.stdout), - collectStderr(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : "Failed to run tailscale status.", - null, - ), - ), - ); - if (exitCode !== 0) { - return yield* tailscaleCommandError( - args, - `Tailscale status exited with code ${exitCode}.`, - exitCode, - stderr, + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: "status" as const, + argumentCount: args.length, + }; + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(executable, args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); - } - return yield* parseTailscaleStatus(stdout); -}).pipe( - Effect.scoped, - Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + return yield* parseTailscaleStatus(stdout); + }).pipe( + Effect.scoped, + Effect.timeout(TAILSCALE_STATUS_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail( - tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null), + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), + cause, + }), ), - onSome: Effect.succeed, }), - ), -); + ); +}); export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; @@ -204,53 +246,52 @@ export function buildTailscaleHttpsBaseUrl(input: { const runTailscaleCommand = ( args: readonly string[], - input: { - readonly spawnMessage: string; - readonly runMessage: string; - readonly exitMessage: (exitCode: number) => string; - readonly timeoutMessage: string; - readonly timeout: Duration.Input; - }, + timeoutInput: Duration.Input, ): Effect.Effect => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const child = yield* spawner - .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) - .pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : input.spawnMessage, - null, - ), - ), + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: "serve" as const, + argumentCount: args.length, + }; + const timeout = Duration.fromInputUnsafe(timeoutInput); + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(executable, args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); + const [stderr, exitCode] = yield* Effect.all( + [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); - const [stderr, exitCode] = yield* Effect.all( - [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : input.runMessage, - null, - ), - ), - ); - if (exitCode !== 0) { - return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr); - } - }).pipe( - Effect.scoped, - Effect.timeoutOption(input.timeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), - onSome: Effect.succeed, + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stderrLength: stderr.length, + }); + } + }).pipe( + Effect.scoped, + Effect.timeout(timeout), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(timeout), + cause, + }), + ), }), - ), - ); + ); + }); export const ensureTailscaleServe = (input: { readonly localPort: number; @@ -260,13 +301,7 @@ export const ensureTailscaleServe = (input: { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; const localHost = input.localHost ?? "127.0.0.1"; const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; - return runTailscaleCommand(args, { - spawnMessage: "Failed to spawn tailscale serve.", - runMessage: "Failed to run tailscale serve.", - exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, - timeoutMessage: "Tailscale serve timed out.", - timeout: TAILSCALE_SERVE_TIMEOUT, - }); + return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); }; export const disableTailscaleServe = ( @@ -276,13 +311,10 @@ export const disableTailscaleServe = ( ): Effect.Effect => Effect.gen(function* () { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], { - spawnMessage: "Failed to spawn tailscale serve off.", - runMessage: "Failed to run tailscale serve off.", - exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, - timeoutMessage: "Tailscale serve off timed out.", - timeout: TAILSCALE_SERVE_TIMEOUT, - }); + return yield* runTailscaleCommand( + ["serve", `--https=${servePort}`, "off"], + TAILSCALE_SERVE_TIMEOUT, + ); }); export const probeTailscaleHttpsEndpoint = (input: { From 97487d9959554854698bf92e672b758e2c3fdeab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:57:48 -0700 Subject: [PATCH 145/257] [codex] Structure reference repo sync failures (#3281) Co-authored-by: codex --- scripts/sync-reference-repos.test.ts | 140 +++++++++++++++++++++++-- scripts/sync-reference-repos.ts | 147 ++++++++++++++++++++------- 2 files changed, 241 insertions(+), 46 deletions(-) diff --git a/scripts/sync-reference-repos.test.ts b/scripts/sync-reference-repos.test.ts index aa415dda529..aa7aee57873 100644 --- a/scripts/sync-reference-repos.test.ts +++ b/scripts/sync-reference-repos.test.ts @@ -19,16 +19,22 @@ const encoder = new TextEncoder(); const effectSmol = referenceRepos[0]!; const alchemyEffect = referenceRepos[1]!; -function mockHandle() { +function mockHandle( + options: { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + } = {}, +) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode ?? 0)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode("done\n")), - stderr: Stream.empty, + stdout: Stream.make(encoder.encode(options.stdout ?? "done\n")), + stderr: Stream.make(encoder.encode(options.stderr ?? "")), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -37,6 +43,7 @@ function mockHandle() { function mockSpawnerLayer( commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>, + handle = mockHandle(), ) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -49,7 +56,7 @@ function mockSpawnerLayer( command: childProcess.command, args: childProcess.args, }); - return Effect.succeed(mockHandle()); + return Effect.succeed(handle); }), ); } @@ -85,6 +92,75 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => { }), ); + it.effect("preserves version source read context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "sync-reference-repos-read-error-", + }); + const sourcePath = path.join(rootDir, effectSmol.versionSourcePath); + + const error = yield* resolveReferenceRepoRef(effectSmol, rootDir, false).pipe(Effect.flip); + + if (error._tag !== "ReferenceRepoVersionSourceError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "read"); + assert.equal(error.repoId, effectSmol.id); + assert.equal(error.sourcePath, sourcePath); + assert.ok(error.cause !== undefined); + assert.ok(!error.message.includes(String((error.cause as Error).message))); + }), + ); + + it.effect("preserves version source parse context and the schema cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "sync-reference-repos-parse-error-", + }); + const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath); + yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); + yield* fs.writeFileString(sourcePath, "{"); + + const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip); + + if (error._tag !== "ReferenceRepoVersionSourceError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "parse"); + assert.equal(error.repoId, alchemyEffect.id); + assert.equal(error.sourcePath, sourcePath); + assert.ok(error.cause !== undefined); + assert.ok(!error.message.includes(String((error.cause as Error).message))); + }), + ); + + it.effect("reports the unresolved package path without inventing a cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "sync-reference-repos-resolution-error-", + }); + const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath); + yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); + yield* fs.writeFileString(sourcePath, '{"dependencies":{}}'); + + const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip); + + if (error._tag !== "ReferenceRepoVersionResolutionError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.repoId, alchemyEffect.id); + assert.equal(error.sourcePath, sourcePath); + assert.deepStrictEqual(error.packageVersionPath, ["dependencies", "alchemy"]); + assert.ok(!("cause" in error)); + }), + ); + it.effect("resolves the alchemy-effect tag from the relay package", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -171,10 +247,56 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => { dryRun: true, }).pipe(Effect.flip); - assert.equal( - error.message, - "Unknown reference repo 'missing'. Expected one of: effect-smol, alchemy-effect.", - ); + if (error._tag !== "ReferenceRepoSelectionError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.repoId, "missing"); + assert.deepStrictEqual(error.expectedRepoIds, ["effect-smol", "alchemy-effect"]); + assert.ok(!("cause" in error)); }), ); + + it.effect("reports non-zero git exits without retaining process output", () => { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "sync-reference-repos-exit-error-", + }); + yield* fs.writeFileString( + path.join(rootDir, "pnpm-workspace.yaml"), + "catalog:\n effect: 4.0.0-beta.73\n", + ); + + const error = yield* syncReferenceRepos({ rootDir, repoId: "effect-smol" }).pipe( + Effect.provide( + mockSpawnerLayer( + commands, + mockHandle({ exitCode: 23, stderr: "subtree failed secret-token-value\n" }), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReferenceRepoGitSubtreeError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "exit"); + assert.equal(error.repoId, effectSmol.id); + assert.equal(error.action, "add"); + assert.equal(error.repository, effectSmol.repository); + assert.equal(error.ref, "effect@4.0.0-beta.73"); + assert.equal(error.rootDir, rootDir); + assert.equal(error.argumentCount, commands[0]?.args.length); + assert.equal(error.exitCode, 23); + assert.equal(error.stdoutLength, 5); + assert.equal(error.stderrLength, 34); + assert.notProperty(error, "args"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "secret-token-value"); + assert.ok(!("cause" in error)); + }); + }); }); diff --git a/scripts/sync-reference-repos.ts b/scripts/sync-reference-repos.ts index fa267b10179..b0bc57ad870 100644 --- a/scripts/sync-reference-repos.ts +++ b/scripts/sync-reference-repos.ts @@ -3,7 +3,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Console from "effect/Console"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -32,10 +31,74 @@ export interface ReferenceRepoSyncPlan { readonly args: ReadonlyArray; } -export class ReferenceRepoSyncError extends Data.TaggedError("ReferenceRepoSyncError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class ReferenceRepoSelectionError extends Schema.TaggedErrorClass()( + "ReferenceRepoSelectionError", + { + repoId: Schema.String, + expectedRepoIds: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Unknown reference repo "${this.repoId}". Expected one of: ${this.expectedRepoIds.join(", ")}.`; + } +} + +export class ReferenceRepoVersionSourceError extends Schema.TaggedErrorClass()( + "ReferenceRepoVersionSourceError", + { + operation: Schema.Literals(["read", "parse"]), + repoId: Schema.String, + sourcePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Reference repo "${this.repoId}" version source operation "${this.operation}" failed for ${this.sourcePath}.`; + } +} + +export class ReferenceRepoVersionResolutionError extends Schema.TaggedErrorClass()( + "ReferenceRepoVersionResolutionError", + { + repoId: Schema.String, + sourcePath: Schema.String, + packageVersionPath: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `No version was found for reference repo "${this.repoId}" at ${this.sourcePath}:${this.packageVersionPath.join(".")}.`; + } +} + +export class ReferenceRepoGitSubtreeError extends Schema.TaggedErrorClass()( + "ReferenceRepoGitSubtreeError", + { + operation: Schema.Literals(["spawn", "communicate", "exit"]), + repoId: Schema.String, + action: Schema.Literals(["add", "pull"]), + repository: Schema.String, + ref: Schema.String, + rootDir: Schema.String, + argumentCount: Schema.Number, + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Git subtree ${this.action} for reference repo "${this.repoId}" failed during "${this.operation}".`; + } +} + +export const ReferenceRepoSyncError = Schema.Union([ + ReferenceRepoSelectionError, + ReferenceRepoVersionSourceError, + ReferenceRepoVersionResolutionError, + ReferenceRepoGitSubtreeError, +]); +export type ReferenceRepoSyncError = typeof ReferenceRepoSyncError.Type; +export const isReferenceRepoSyncError = Schema.is(ReferenceRepoSyncError); const decodeJsonSource = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown)); @@ -61,26 +124,21 @@ function readNestedString(input: unknown, keys: ReadonlyArray): string | } function decodeVersionSource( + repo: ReferenceRepo, sourcePath: string, content: string, ): Effect.Effect { - if (sourcePath.endsWith(".yaml") || sourcePath.endsWith(".yml")) { - return decodeYamlSource(content).pipe( - Effect.mapError( - (cause) => - new ReferenceRepoSyncError({ - message: `Unable to parse version source ${sourcePath}.`, - cause, - }), - ), - ); - } - - return decodeJsonSource(content).pipe( + const decode = + repo.versionSourcePath.endsWith(".yaml") || repo.versionSourcePath.endsWith(".yml") + ? decodeYamlSource + : decodeJsonSource; + return decode(content).pipe( Effect.mapError( (cause) => - new ReferenceRepoSyncError({ - message: `Unable to parse version source ${sourcePath}.`, + new ReferenceRepoVersionSourceError({ + operation: "parse", + repoId: repo.id, + sourcePath, cause, }), ), @@ -98,10 +156,9 @@ function getSelectedRepos( return repo ? Effect.succeed([repo]) : Effect.fail( - new ReferenceRepoSyncError({ - message: `Unknown reference repo '${repoId}'. Expected one of: ${referenceRepos - .map((candidate) => candidate.id) - .join(", ")}.`, + new ReferenceRepoSelectionError({ + repoId, + expectedRepoIds: referenceRepos.map((candidate) => candidate.id), }), ); } @@ -121,20 +178,22 @@ export const resolveReferenceRepoRef = Effect.fn("resolveReferenceRepoRef")(func const versionSourceContent = yield* fs.readFileString(versionSourcePath).pipe( Effect.mapError( (cause) => - new ReferenceRepoSyncError({ - message: `Unable to read package version for '${repo.id}' from ${versionSourcePath}.`, + new ReferenceRepoVersionSourceError({ + operation: "read", + repoId: repo.id, + sourcePath: versionSourcePath, cause, }), ), ); - const versionSource = yield* decodeVersionSource(repo.versionSourcePath, versionSourceContent); + const versionSource = yield* decodeVersionSource(repo, versionSourcePath, versionSourceContent); const version = readNestedString(versionSource, repo.packageVersionPath); if (!version) { - return yield* new ReferenceRepoSyncError({ - message: `Unable to resolve package version for '${repo.id}' at ${repo.versionSourcePath}:${repo.packageVersionPath.join( - ".", - )}.`, + return yield* new ReferenceRepoVersionResolutionError({ + repoId: repo.id, + sourcePath: versionSourcePath, + packageVersionPath: repo.packageVersionPath, }); } @@ -163,11 +222,20 @@ export const planReferenceRepoSync = Effect.fn("planReferenceRepoSync")(function const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRepoSyncPlan) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const errorContext = { + repoId: plan.repo.id, + action: plan.action, + repository: plan.repo.repository, + ref: plan.ref, + rootDir, + argumentCount: plan.args.length, + } as const; const child = yield* spawner.spawn(ChildProcess.make("git", plan.args, { cwd: rootDir })).pipe( Effect.mapError( (cause) => - new ReferenceRepoSyncError({ - message: `Unable to start git subtree ${plan.action} for '${plan.repo.id}'.`, + new ReferenceRepoGitSubtreeError({ + ...errorContext, + operation: "spawn", cause, }), ), @@ -182,16 +250,21 @@ const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRe ).pipe( Effect.mapError( (cause) => - new ReferenceRepoSyncError({ - message: `Unable to run git subtree ${plan.action} for '${plan.repo.id}'.`, + new ReferenceRepoGitSubtreeError({ + ...errorContext, + operation: "communicate", cause, }), ), ); if (exitCode !== 0) { - return yield* new ReferenceRepoSyncError({ - message: `git subtree ${plan.action} failed for '${plan.repo.id}' with exit code ${exitCode}.\n${stderr.trim()}`, + return yield* new ReferenceRepoGitSubtreeError({ + ...errorContext, + operation: "exit", + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, }); } From f50a7cddc19336e3f4de820e198564f7e7f75455 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:58:24 -0700 Subject: [PATCH 146/257] [codex] Structure dev-runner failures (#3283) Co-authored-by: codex --- scripts/dev-runner.test.ts | 217 +++++++++++++++++++++++++++++++++++-- scripts/dev-runner.ts | 182 ++++++++++++++++++++++++------- 2 files changed, 348 insertions(+), 51 deletions(-) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index f6df387ee22..85d57c4181f 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,8 +1,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { checkPortAvailabilityOnHosts, @@ -11,8 +19,49 @@ import { getDevRunnerModeArgs, resolveModePortOffsets, resolveOffset, + runDevRunnerWithInput, } from "./dev-runner.ts"; +const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); +const netServiceLayer = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(49_152), + findAvailablePort: (port) => Effect.succeed(port), +}); + +function mockProcess(exit: number | PlatformError.PlatformError) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: + typeof exit === "number" + ? Effect.succeed(ChildProcessSpawner.ExitCode(exit)) + : Effect.fail(exit), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +const devServerInput = { + mode: "dev:server", + t3Home: "/tmp/t3code-dev-runner", + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: 13_773, + devUrl: undefined, + dryRun: false, + runArgs: ["--inspect", "secret-token-value"], +} as const; + it.layer(NodeServices.layer)("dev-runner", (it) => { describe("getDevRunnerModeArgs", () => { it.effect("lets Vite+ honor the desktop dev task graph", () => @@ -42,8 +91,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("resolveOffset", () => { it.effect("uses explicit T3CODE_PORT_OFFSET when provided", () => - Effect.sync(() => { - const result = resolveOffset({ portOffset: 12, devInstance: undefined }); + Effect.gen(function* () { + const result = yield* resolveOffset({ portOffset: 12, devInstance: undefined }); assert.deepStrictEqual(result, { offset: 12, source: "T3CODE_PORT_OFFSET=12", @@ -52,23 +101,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); it.effect("hashes non-numeric instance values", () => - Effect.sync(() => { - const result = resolveOffset({ portOffset: undefined, devInstance: "feature-branch" }); + Effect.gen(function* () { + const result = yield* resolveOffset({ + portOffset: undefined, + devInstance: "feature-branch", + }); assert.ok(result.offset >= 1); assert.ok(result.offset <= 3000); }), ); - it.effect("throws for negative port offset", () => + it.effect("returns structured context for a negative port offset", () => Effect.gen(function* () { - const error = yield* Effect.flip( - Effect.try({ - try: () => resolveOffset({ portOffset: -1, devInstance: undefined }), - catch: (cause) => String(cause), - }), + const error = yield* resolveOffset({ portOffset: -1, devInstance: undefined }).pipe( + Effect.flip, ); - assert.ok(error.includes("Invalid T3CODE_PORT_OFFSET")); + assert.equal(error._tag, "DevRunnerInvalidPortOffsetError"); + assert.equal(error.configKey, "T3CODE_PORT_OFFSET"); + assert.equal(error.portOffset, -1); + assert.equal(error.minimum, 0); + assert.ok(!("cause" in error)); }), ); }); @@ -289,6 +342,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(offset, 59_802); }), ); + + it.effect("reports the exhausted range and required port set", () => + Effect.gen(function* () { + const error = yield* findFirstAvailableOffset({ + startOffset: 51_763, + requireServerPort: true, + requireWebPort: false, + checkPortAvailability: () => Effect.succeed(true), + }).pipe(Effect.flip); + + if (error._tag !== "DevRunnerPortExhaustedError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.startOffset, 51_763); + assert.equal(error.requireServerPort, true); + assert.equal(error.requireWebPort, false); + assert.equal(error.baseServerPort, 13_773); + assert.equal(error.baseWebPort, 5_733); + assert.equal(error.maximumPort, 65_535); + assert.ok(!("cause" in error)); + }), + ); }); describe("checkPortAvailabilityOnHosts", () => { @@ -395,4 +470,124 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); }); + + describe("runDevRunnerWithInput", () => { + it.effect("preserves invalid configuration as the exact cause", () => + Effect.gen(function* () { + const error = yield* runDevRunnerWithInput({ ...devServerInput, dryRun: true }).pipe( + Effect.provide( + Layer.merge( + netServiceLayer, + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_PORT_OFFSET: "not-an-integer" } }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "DevRunnerConfigurationError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.deepStrictEqual(error.configKeys, ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"]); + assert.ok(error.cause !== undefined); + assert.ok(!error.message.includes(String((error.cause as Error).message))); + }), + ); + + it.effect("preserves process spawn context and the exact platform cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "vp was not found", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.mode, "dev:server"); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.ok(!error.message.includes(cause.message)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); + }); + }); + + it.effect("reports non-zero exits without manufacturing a cause", () => { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(mockProcess(17))), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessExitError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.mode, "dev:server"); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); + assert.equal(error.shell, false); + assert.equal(error.exitCode, 17); + assert.ok(!("cause" in error)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); + }); + }); + + it.effect("preserves wait-for-exit failures as the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + description: "process status became unavailable", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(mockProcess(cause))), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "wait-for-exit"); + assert.equal(error.mode, "dev:server"); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.ok(!error.message.includes(cause.message)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); + }); + }); + }); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 36c5aa41852..fb82310bbd3 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -8,7 +8,6 @@ import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Hash from "effect/Hash"; import * as Layer from "effect/Layer"; @@ -57,10 +56,87 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray { return MODE_ARGS[mode]; } -class DevRunnerError extends Data.TaggedError("DevRunnerError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class DevRunnerConfigurationError extends Schema.TaggedErrorClass()( + "DevRunnerConfigurationError", + { + configKeys: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read dev-runner configuration: ${this.configKeys.join(", ")}.`; + } +} + +export class DevRunnerInvalidPortOffsetError extends Schema.TaggedErrorClass()( + "DevRunnerInvalidPortOffsetError", + { + configKey: Schema.Literal("T3CODE_PORT_OFFSET"), + portOffset: Schema.Number, + minimum: Schema.Number, + }, +) { + override get message(): string { + return `${this.configKey} must be at least ${this.minimum}; received ${this.portOffset}.`; + } +} + +export class DevRunnerPortExhaustedError extends Schema.TaggedErrorClass()( + "DevRunnerPortExhaustedError", + { + startOffset: Schema.Number, + requireServerPort: Schema.Boolean, + requireWebPort: Schema.Boolean, + baseServerPort: Schema.Number, + baseWebPort: Schema.Number, + maximumPort: Schema.Number, + }, +) { + override get message(): string { + return `No required dev ports were available from offset ${this.startOffset} through maximum port ${this.maximumPort}.`; + } +} + +export class DevRunnerProcessError extends Schema.TaggedErrorClass()( + "DevRunnerProcessError", + { + operation: Schema.Literals(["spawn", "wait-for-exit"]), + mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + executable: Schema.Literal("vp"), + argumentCount: Schema.Number, + shell: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Dev-runner process operation "${this.operation}" failed for mode "${this.mode}".`; + } +} + +export class DevRunnerProcessExitError extends Schema.TaggedErrorClass()( + "DevRunnerProcessExitError", + { + mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + executable: Schema.Literal("vp"), + argumentCount: Schema.Number, + shell: Schema.Boolean, + exitCode: Schema.Number, + }, +) { + override get message(): string { + return `Dev-runner process exited with code ${this.exitCode} in mode "${this.mode}".`; + } +} + +export const DevRunnerError = Schema.Union([ + DevRunnerConfigurationError, + DevRunnerInvalidPortOffsetError, + DevRunnerPortExhaustedError, + DevRunnerProcessError, + DevRunnerProcessExitError, +]); +export type DevRunnerError = typeof DevRunnerError.Type; +export const isDevRunnerError = Schema.is(DevRunnerError); const optionalStringConfig = (name: string): Config.Config => Config.string(name).pipe( @@ -96,28 +172,40 @@ const OffsetConfig = Config.all({ export function resolveOffset(config: { readonly portOffset: number | undefined; readonly devInstance: string | undefined; -}): { readonly offset: number; readonly source: string } { +}): Effect.Effect< + { readonly offset: number; readonly source: string }, + DevRunnerInvalidPortOffsetError +> { if (config.portOffset !== undefined) { if (config.portOffset < 0) { - throw new Error(`Invalid T3CODE_PORT_OFFSET: ${config.portOffset}`); + return Effect.fail( + new DevRunnerInvalidPortOffsetError({ + configKey: "T3CODE_PORT_OFFSET", + portOffset: config.portOffset, + minimum: 0, + }), + ); } - return { + return Effect.succeed({ offset: config.portOffset, source: `T3CODE_PORT_OFFSET=${config.portOffset}`, - }; + }); } const seed = config.devInstance?.trim(); if (!seed) { - return { offset: 0, source: "default ports" }; + return Effect.succeed({ offset: 0, source: "default ports" }); } if (/^\d+$/.test(seed)) { - return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; + return Effect.succeed({ + offset: Number(seed), + source: `numeric T3CODE_DEV_INSTANCE=${seed}`, + }); } const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; - return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; + return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); } function resolveBaseDir(baseDir: string | undefined): Effect.Effect { @@ -275,7 +363,7 @@ export function findFirstAvailableOffset({ requireServerPort, requireWebPort, checkPortAvailability, -}: FindFirstAvailableOffsetInput): Effect.Effect { +}: FindFirstAvailableOffsetInput): Effect.Effect { return Effect.gen(function* () { const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck; @@ -311,8 +399,13 @@ export function findFirstAvailableOffset({ } } - return yield* new DevRunnerError({ - message: `No available dev ports found from offset ${startOffset}. Tried server=${BASE_SERVER_PORT}+n web=${BASE_WEB_PORT}+n up to port ${MAX_PORT}.`, + return yield* new DevRunnerPortExhaustedError({ + startOffset, + requireServerPort, + requireWebPort, + baseServerPort: BASE_SERVER_PORT, + baseWebPort: BASE_WEB_PORT, + maximumPort: MAX_PORT, }); }); } @@ -333,7 +426,7 @@ export function resolveModePortOffsets({ checkPortAvailability, }: ResolveModePortOffsetsInput): Effect.Effect< { readonly serverOffset: number; readonly webOffset: number }, - DevRunnerError, + DevRunnerPortExhaustedError, R > { return Effect.gen(function* () { @@ -397,21 +490,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const { portOffset, devInstance } = yield* OffsetConfig.pipe( Effect.mapError( (cause) => - new DevRunnerError({ - message: "Failed to read T3CODE_PORT_OFFSET/T3CODE_DEV_INSTANCE configuration.", + new DevRunnerConfigurationError({ + configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"], cause, }), ), ); - const { offset, source } = yield* Effect.try({ - try: () => resolveOffset({ portOffset, devInstance }), - catch: (cause) => - new DevRunnerError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); + const { offset, source } = yield* resolveOffset({ portOffset, devInstance }); const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, @@ -453,6 +539,12 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { [...MODE_ARGS[input.mode], ...input.runArgs], { env }, ); + const processContext = { + mode: input.mode, + executable: "vp" as const, + argumentCount: spawnCommand.args.length, + shell: spawnCommand.shell, + } as const; const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { stdin: "inherit", stdout: "inherit", @@ -465,24 +557,34 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { // which would put the runner in a new group and require manual forwarding. detached: false, forceKillAfter: "1500 millis", - }); + }).pipe( + Effect.mapError( + (cause) => + new DevRunnerProcessError({ + ...processContext, + operation: "spawn", + cause, + }), + ), + ); - const exitCode = yield* child.exitCode; + const exitCode = yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new DevRunnerProcessError({ + ...processContext, + operation: "wait-for-exit", + cause, + }), + ), + ); if (exitCode !== 0) { - return yield* new DevRunnerError({ - message: `vp run exited with code ${exitCode}`, + return yield* new DevRunnerProcessExitError({ + ...processContext, + exitCode, }); } - }).pipe( - Effect.mapError((cause) => - cause instanceof DevRunnerError - ? cause - : new DevRunnerError({ - message: cause instanceof Error ? cause.message : "dev-runner failed", - cause, - }), - ), - ); + }); } const devRunnerCli = Command.make("dev-runner", { From be4a38e57876d95f85a8cb12497a652a575cad0c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:59:27 -0700 Subject: [PATCH 147/257] [codex] add structured Electron dialog errors (#3284) Co-authored-by: codex --- .../src/electron/ElectronDialog.test.ts | 113 ++++++++++++++ apps/desktop/src/electron/ElectronDialog.ts | 143 ++++++++++++++++-- 2 files changed, 242 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 9be62e740b2..388b3fd2c15 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { BrowserWindow } from "electron"; @@ -90,4 +91,116 @@ describe("ElectronDialog", () => { ]); }).pipe(Effect.provide(ElectronDialog.layer)), ); + + it.effect("preserves folder picker request context and cause", () => + Effect.gen(function* () { + const cause = new Error("folder picker failed"); + const owner = { id: 7 } as BrowserWindow; + showOpenDialogMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.pickFolder({ + owner: Option.some(owner), + defaultPath: Option.some("/workspace"), + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); + assert.isTrue(ElectronDialog.isElectronDialogError(error)); + assert.strictEqual(error.ownerWindowId, 7); + assert.strictEqual(error.defaultPath, "/workspace"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "window 7"); + assert.include(error.message, "/workspace"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves confirmation request context and cause", () => + Effect.gen(function* () { + const cause = new Error("confirmation failed"); + const owner = { id: 9 } as BrowserWindow; + showMessageBoxMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.confirm({ + owner: Option.some(owner), + message: " Confirm removal? ", + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError); + assert.strictEqual(error.ownerWindowId, 9); + assert.strictEqual(error.promptLength, "Confirm removal?".length); + assert.notProperty(error, "promptMessage"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "window 9"); + assert.notInclude(error.message, "Confirm removal?"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves message box request context and cause", () => + Effect.gen(function* () { + const cause = new Error("message box failed"); + showMessageBoxMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.showMessageBox({ + type: "warning", + title: "Unsaved changes", + message: "Discard changes?", + detail: "This cannot be undone.", + buttons: ["Cancel", "Discard"], + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogShowMessageBoxError); + assert.strictEqual(error.type, "warning"); + assert.strictEqual(error.titleLength, "Unsaved changes".length); + assert.strictEqual(error.messageLength, "Discard changes?".length); + assert.strictEqual(error.detailLength, "This cannot be undone.".length); + assert.strictEqual(error.buttonCount, 2); + assert.notProperty(error, "title"); + assert.notProperty(error, "dialogMessage"); + assert.notProperty(error, "dialogDetail"); + assert.notProperty(error, "buttons"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "warning"); + assert.notInclude(error.message, "Unsaved changes"); + assert.notInclude(error.message, "Discard changes?"); + assert.notInclude(error.message, "This cannot be undone."); + assert.notInclude(error.message, "Cancel"); + assert.notInclude(error.message, "Discard"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves error box request context and cause in the defect", () => + Effect.gen(function* () { + const cause = new Error("error box failed"); + showErrorBoxMock.mockImplementation(() => { + throw cause; + }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const exit = yield* Effect.exit(dialog.showErrorBox("Startup failed", "Could not start.")); + + assert.isTrue(exit._tag === "Failure"); + if (exit._tag === "Success") return; + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronDialog.ElectronDialogShowErrorBoxError); + assert.strictEqual(error.titleLength, "Startup failed".length); + assert.strictEqual(error.contentLength, "Could not start.".length); + assert.notProperty(error, "title"); + assert.notProperty(error, "content"); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, "Startup failed"); + assert.notInclude(error.message, "Could not start."); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 057817ec7e6..be633971bea 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -2,11 +2,80 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; const CONFIRM_BUTTON_INDEX = 1; +export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickFolderError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + defaultPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath; + return `Failed to open the Electron folder picker for ${owner} with ${defaultPath}.`; + } +} + +export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( + "ElectronDialogConfirmError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + promptLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + return `Failed to open an Electron confirmation dialog for ${owner} with a ${this.promptLength}-character prompt.`; + } +} + +export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass()( + "ElectronDialogShowMessageBoxError", + { + type: Schema.NullOr(Schema.Literals(["none", "info", "error", "question", "warning"])), + titleLength: Schema.NullOr(Schema.Number), + messageLength: Schema.Number, + detailLength: Schema.NullOr(Schema.Number), + buttonCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const type = this.type === null ? "untyped" : this.type; + return `Failed to show the Electron ${type} message box with ${this.buttonCount} buttons.`; + } +} + +export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass()( + "ElectronDialogShowErrorBoxError", + { + titleLength: Schema.Number, + contentLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to show the Electron error box with a ${this.titleLength}-character title and ${this.contentLength}-character content.`; + } +} + +export const ElectronDialogError = Schema.Union([ + ElectronDialogPickFolderError, + ElectronDialogConfirmError, + ElectronDialogShowMessageBoxError, + ElectronDialogShowErrorBoxError, +]); +export type ElectronDialogError = typeof ElectronDialogError.Type; +export const isElectronDialogError = Schema.is(ElectronDialogError); + export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; readonly defaultPath: Option.Option; @@ -22,17 +91,24 @@ export class ElectronDialog extends Context.Service< { readonly pickFolder: ( input: ElectronDialogPickFolderInput, - ) => Effect.Effect>; - readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; + ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly confirm: ( + input: ElectronDialogConfirmInput, + ) => Effect.Effect; readonly showMessageBox: ( options: Electron.MessageBoxOptions, - ) => Effect.Effect; + ) => Effect.Effect; readonly showErrorBox: (title: string, content: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronDialog") {} export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { onNone: () => ({ properties: ["openDirectory", "createDirectory"], @@ -42,10 +118,18 @@ export const make = ElectronDialog.of({ defaultPath, }), }); - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), - onSome: (owner) => - Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFolderError({ + ownerWindowId, + defaultPath, + cause, + }), }); if (result.canceled) { @@ -67,17 +151,48 @@ export const make = ElectronDialog.of({ noLink: true, message: normalizedMessage, }; - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), - onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showMessageBox(options), + onSome: (owner) => Electron.dialog.showMessageBox(owner, options), + }), + catch: (cause) => + new ElectronDialogConfirmError({ + ownerWindowId, + promptLength: normalizedMessage.length, + cause, + }), }); return result.response === CONFIRM_BUTTON_INDEX; }), - showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), - showErrorBox: (title, content) => - Effect.sync(() => { - Electron.dialog.showErrorBox(title, content); + showMessageBox: (options) => + Effect.tryPromise({ + try: () => Electron.dialog.showMessageBox(options), + catch: (cause) => + new ElectronDialogShowMessageBoxError({ + type: options.type ?? null, + titleLength: options.title?.length ?? null, + messageLength: options.message.length, + detailLength: options.detail?.length ?? null, + buttonCount: options.buttons?.length ?? 0, + cause, + }), }), + showErrorBox: (title, content) => + Effect.try({ + try: () => Electron.dialog.showErrorBox(title, content), + catch: (cause) => + new ElectronDialogShowErrorBoxError({ + titleLength: title.length, + contentLength: content.length, + cause, + }), + }).pipe(Effect.orDie), }); export const layer = Layer.succeed(ElectronDialog, make); From 204984bf8cc4c9b85ef4d100b355c6c0caf57a21 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:00:04 -0700 Subject: [PATCH 148/257] [codex] Structure relay deploy output errors (#3340) Co-authored-by: codex --- infra/relay/scripts/deploy.test.ts | 44 +++++++ infra/relay/scripts/deploy.ts | 177 +++++++++++++++++++---------- 2 files changed, 163 insertions(+), 58 deletions(-) diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index 992cb2d887b..87098b25daf 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -6,13 +6,57 @@ import * as Path from "effect/Path"; import { hasDeployChanges, + missingRelayPublicConfigFields, publicConfigFromOutput, reconcileRootEnvPublicConfig, reconcileRootEnvRelayUrl, + RelayDeployError, + RelayDeployPublicConfigUnavailableError, serializeGithubOutput, serializeRelayClientTracingEnvironment, } from "./deploy.ts"; +describe("RelayDeployError", () => { + it("reports the incomplete state source, stage, and missing fields", () => { + const missingFields = missingRelayPublicConfigFields({ + url: "https://relay.example.test", + mobileTracingUrl: "https://api.axiom.co/v1/traces", + }); + const error = new RelayDeployError({ + source: "alchemy_state", + stage: "production", + missingFields, + }); + + expect(error).toMatchObject({ + source: "alchemy_state", + stage: "production", + missingFields: [ + "mobileTracingDataset", + "mobileTracingToken", + "clientTracingUrl", + "clientTracingDataset", + "clientTracingToken", + ], + }); + expect(error.message).toBe( + "Relay deploy output from 'alchemy_state' for stage 'production' is missing required public config fields: mobileTracingDataset, mobileTracingToken, clientTracingUrl, clientTracingDataset, clientTracingToken", + ); + }); + + it("distinguishes deploy results that do not produce public config", () => { + const error = new RelayDeployPublicConfigUnavailableError({ + result: "dry-run", + stage: "production", + outputPath: "/tmp/relay-client.env", + }); + + expect(error.message).toBe( + "Relay deploy result 'dry-run' for stage 'production' did not produce public config required by GitHub environment output '/tmp/relay-client.env'.", + ); + }); +}); + describe("hasDeployChanges", () => { it("detects resource, binding, and deletion changes", () => { expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false); diff --git a/infra/relay/scripts/deploy.ts b/infra/relay/scripts/deploy.ts index 2ef82ec4ffa..698df3d5476 100644 --- a/infra/relay/scripts/deploy.ts +++ b/infra/relay/scripts/deploy.ts @@ -19,21 +19,65 @@ import { PlatformServices } from "alchemy/Util/PlatformServices"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Console from "effect/Console"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; import { Command, Flag, Prompt } from "effect/unstable/cli"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import RelayStack from "../alchemy.run.ts"; -export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{ - readonly message: string; -}> {} +const relayDeployOutputFields = [ + "url", + "mobileTracingUrl", + "mobileTracingDataset", + "mobileTracingToken", + "clientTracingUrl", + "clientTracingDataset", + "clientTracingToken", +] as const; + +export const RelayDeployOutputField = Schema.Literals(relayDeployOutputFields); +export type RelayDeployOutputField = typeof RelayDeployOutputField.Type; + +export const RelayDeployResult = Schema.Literals([ + "applied", + "noop", + "dry-run", + "cancelled", + "state", +]); +export type RelayDeployResult = typeof RelayDeployResult.Type; + +export class RelayDeployError extends Schema.TaggedErrorClass()( + "RelayDeployError", + { + source: Schema.Literals(["alchemy_state", "alchemy_apply"]), + stage: Schema.String, + missingFields: Schema.Array(RelayDeployOutputField), + }, +) { + override get message(): string { + return `Relay deploy output from '${this.source}' for stage '${this.stage}' is missing required public config fields: ${this.missingFields.join(", ")}`; + } +} + +export class RelayDeployPublicConfigUnavailableError extends Schema.TaggedErrorClass()( + "RelayDeployPublicConfigUnavailableError", + { + result: RelayDeployResult, + stage: Schema.String, + outputPath: Schema.String, + }, +) { + override get message(): string { + return `Relay deploy result '${this.result}' for stage '${this.stage}' did not produce public config required by GitHub environment output '${this.outputPath}'.`; + } +} export interface RelayDeployOptions { readonly dryRun: boolean; @@ -112,8 +156,6 @@ export function hasDeployChanges(plan: Plan.Plan): boolean { ); } -export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled" | "state"; - export interface RelayDeployOutcome { readonly result: RelayDeployResult; readonly changed: boolean; @@ -199,10 +241,13 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* ( outcome: RelayDeployOutcome, outputPath: string, + stage: string, ) { if (Option.isNone(outcome.publicConfig)) { - return yield* new RelayDeployError({ - message: "Relay public client config is unavailable for the GitHub environment file", + return yield* new RelayDeployPublicConfigUnavailableError({ + result: outcome.result, + stage, + outputPath, }); } const fs = yield* FileSystem.FileSystem; @@ -224,44 +269,71 @@ const deployBaseServices = Layer.mergeAll( ); const deployServices = deployBaseServices; -export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null { +function relayPublicConfigValues( + output: unknown, +): Readonly> { if (typeof output !== "object" || output === null) { - return null; + return { + url: undefined, + mobileTracingUrl: undefined, + mobileTracingDataset: undefined, + mobileTracingToken: undefined, + clientTracingUrl: undefined, + clientTracingDataset: undefined, + clientTracingToken: undefined, + }; } const value = output as Record; - const text = (name: string) => (typeof value[name] === "string" ? value[name] : undefined); + const text = (name: string) => { + const candidate = value[name]; + return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined; + }; const secret = (name: string): string | undefined => { const candidate = value[name]; if (!Redacted.isRedacted(candidate)) { return text(name); } const redacted = Redacted.value(candidate); - return typeof redacted === "string" ? redacted : undefined; + return typeof redacted === "string" && redacted.length > 0 ? redacted : undefined; + }; + return { + url: text("url"), + mobileTracingUrl: text("mobileTracingUrl"), + mobileTracingDataset: text("mobileTracingDataset"), + mobileTracingToken: secret("mobileTracingToken"), + clientTracingUrl: text("clientTracingUrl"), + clientTracingDataset: text("clientTracingDataset"), + clientTracingToken: secret("clientTracingToken"), + }; +} + +export function missingRelayPublicConfigFields( + output: unknown, +): ReadonlyArray { + const values = relayPublicConfigValues(output); + return relayDeployOutputFields.filter((field) => values[field] === undefined); +} + +function hasCompleteRelayPublicConfigValues( + values: Readonly>, +): values is Readonly> { + return relayDeployOutputFields.every((field) => values[field] !== undefined); +} + +export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null { + const values = relayPublicConfigValues(output); + if (!hasCompleteRelayPublicConfigValues(values)) { + return null; + } + return { + relayUrl: values.url, + mobileTracingUrl: values.mobileTracingUrl, + mobileTracingDataset: values.mobileTracingDataset, + mobileTracingToken: values.mobileTracingToken, + clientTracingUrl: values.clientTracingUrl, + clientTracingDataset: values.clientTracingDataset, + clientTracingToken: values.clientTracingToken, }; - const relayUrl = text("url"); - const mobileTracingUrl = text("mobileTracingUrl"); - const mobileTracingDataset = text("mobileTracingDataset"); - const mobileTracingToken = secret("mobileTracingToken"); - const clientTracingUrl = text("clientTracingUrl"); - const clientTracingDataset = text("clientTracingDataset"); - const clientTracingToken = secret("clientTracingToken"); - return relayUrl && - mobileTracingUrl && - mobileTracingDataset && - mobileTracingToken && - clientTracingUrl && - clientTracingDataset && - clientTracingToken - ? { - relayUrl, - mobileTracingUrl, - mobileTracingDataset, - mobileTracingToken, - clientTracingUrl, - clientTracingDataset, - clientTracingToken, - } - : null; } const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) { @@ -271,7 +343,9 @@ const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (sta const publicConfig = publicConfigFromOutput(output); if (publicConfig === null) { return yield* new RelayDeployError({ - message: `Alchemy relay state for stage ${stage} did not include complete public client config`, + source: "alchemy_state", + stage, + missingFields: missingRelayPublicConfigFields(output), }); } return { @@ -285,7 +359,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( function* ( options: RelayDeployOptions, _configProvider: ConfigProvider.ConfigProvider, - _stage: string, + stage: string, ) { const stack = yield* RelayStack; const cli = yield* Cli; @@ -318,31 +392,18 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( } } const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); - if ( - output.url === undefined || - output.mobileTracingUrl === undefined || - output.mobileTracingDataset === undefined || - output.mobileTracingToken === undefined || - output.clientTracingUrl === undefined || - output.clientTracingDataset === undefined || - output.clientTracingToken === undefined - ) { + const publicConfig = publicConfigFromOutput(output); + if (publicConfig === null) { return yield* new RelayDeployError({ - message: "Alchemy relay deploy output did not include complete public client config", + source: "alchemy_apply", + stage, + missingFields: missingRelayPublicConfigFields(output), }); } return { result: changed ? "applied" : "noop", changed, - publicConfig: Option.some({ - relayUrl: output.url, - mobileTracingUrl: output.mobileTracingUrl, - mobileTracingDataset: output.mobileTracingDataset, - mobileTracingToken: Redacted.value(output.mobileTracingToken), - clientTracingUrl: output.clientTracingUrl, - clientTracingDataset: output.clientTracingDataset, - clientTracingToken: Redacted.value(output.clientTracingToken), - }), + publicConfig: Option.some(publicConfig), } satisfies RelayDeployOutcome; }, (effect, options, configProvider, stage) => @@ -379,7 +440,7 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO yield* writeGithubOutput(outcome); } if (Option.isSome(options.githubEnvFile)) { - yield* writeGithubEnvFile(outcome, options.githubEnvFile.value); + yield* writeGithubEnvFile(outcome, options.githubEnvFile.value, stage); } }); From 336d176027d1079fb98554a9439b6a2412c7e2fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:00:54 -0700 Subject: [PATCH 149/257] [codex] Structure preview automation errors (#3272) Co-authored-by: codex --- .../src/mcp/PreviewAutomationBroker.test.ts | 112 +++++++++- .../server/src/mcp/PreviewAutomationBroker.ts | 201 +++++++++++++----- packages/contracts/src/previewAutomation.ts | 198 +++++++++++++++-- 3 files changed, 441 insertions(+), 70 deletions(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 5631b3bef57..9f7ef2113d7 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1,8 +1,10 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, + PreviewAutomationClientDisconnectedError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationMalformedResponseError, PreviewAutomationNoFocusedOwnerError, - PreviewAutomationUnavailableError, ProviderInstanceId, ThreadId, type PreviewAutomationOwner, @@ -59,6 +61,95 @@ it.effect("atomically registers a connected owner and correlates its response", ), ); +it.effect("preserves bounded request and remote selector diagnostics", () => { + const locator = "role=button[name='request-secret']"; + const remoteMessage = "Unexpected token near remote-secret."; + const remoteError = { + _tag: "PreviewAutomationInvalidSelectorError", + message: remoteMessage, + detail: { selector: "role=button[name='remote-secret']" }, + } as const; + + return Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" })); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: false, + error: remoteError, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ + scope, + operation: "click", + input: { locator }, + timeoutMs: 1_234, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationInvalidSelectorError); + expect(error).toMatchObject({ + operation: "click", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + tabId: "tab-1", + timeoutMs: 1_234, + selectorKind: "locator", + selectorLength: locator.length, + remoteTag: "PreviewAutomationInvalidSelectorError", + remoteMessageLength: remoteMessage.length, + remoteDetailKind: "object", + }); + expect(error.message).toBe( + `Preview automation click received an invalid locator (${locator.length} characters).`, + ); + expect(error.message).not.toContain("secret"); + expect(error.cause).toBe(remoteError); + expect("selector" in error).toBe(false); + expect("remoteMessage" in error).toBe(false); + expect("remoteDetail" in error).toBe(false); + }), + ); +}); + +it.effect("distinguishes malformed remote failures", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner()); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: false }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ scope, operation: "status", input: {}, timeoutMs: 2_000 }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationMalformedResponseError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 2_000, + }); + }), + ), +); + it.effect("rejects calls when no focused owner exists", () => Effect.gen(function* () { const broker = yield* PreviewAutomationBroker.make; @@ -66,6 +157,13 @@ it.effect("rejects calls when no focused owner exists", () => .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + }); }), ); @@ -162,7 +260,17 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec const _replacementRequests = yield* broker.connect(makeOwner()); const error = yield* Fiber.join(pending); - expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); + expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 15_000, + }); }), ), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index ee9d5bdbd0d..a2bdb95f061 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -1,12 +1,16 @@ import { + PreviewAutomationClientDisconnectedError, PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, + PreviewAutomationHostNotConnectedError, PreviewAutomationInvalidSelectorError, + PreviewAutomationMalformedResponseError, PreviewAutomationNoFocusedOwnerError, + PreviewAutomationRemoteUnavailableError, + PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, PreviewAutomationTimeoutError, - PreviewAutomationUnavailableError, PreviewAutomationUnsupportedClientError, type PreviewAutomationError, type PreviewAutomationOperation, @@ -62,6 +66,21 @@ interface ClientConnection { interface PendingRequest { readonly queue: ClientConnection["queue"]; readonly deferred: Deferred.Deferred; + readonly context: PreviewAutomationRequestErrorContext; +} + +interface PreviewAutomationRequestErrorContext { + readonly operation: PreviewAutomationOperation; + readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; + readonly threadId: McpInvocationContext.McpInvocationScope["threadId"]; + readonly providerSessionId: string; + readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; + readonly clientId: string; + readonly requestId: string; + readonly tabId?: PreviewTabId; + readonly timeoutMs: number; + readonly selectorKind?: "locator" | "selector"; + readonly selectorLength?: number; } interface BrokerState { @@ -71,48 +90,104 @@ interface BrokerState { readonly requestSequence: number; } -const makeResponseError = ( +const selectorDiagnosticsFromInput = ( + input: unknown, +): Pick => { + if (typeof input !== "object" || input === null) return {}; + if ("locator" in input && typeof input.locator === "string") { + return { selectorKind: "locator", selectorLength: input.locator.length }; + } + if ("selector" in input && typeof input.selector === "string") { + return { selectorKind: "selector", selectorLength: input.selector.length }; + } + return {}; +}; + +type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; + +function remoteDetailKind(detail: unknown): RemoteDetailKind { + if (detail === null) return "null"; + if (Array.isArray(detail)) return "array"; + switch (typeof detail) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + default: + return "object"; + } +} + +const classifyResponseError = ( + context: PreviewAutomationRequestErrorContext, error: NonNullable, ): PreviewAutomationError => { + const remoteDiagnostics = { + remoteTag: error._tag, + remoteMessageLength: error.message.length, + ...(error.detail === undefined ? {} : { remoteDetailKind: remoteDetailKind(error.detail) }), + cause: error, + }; switch (error._tag) { case "PreviewAutomationNoFocusedOwnerError": - return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); + return new PreviewAutomationNoFocusedOwnerError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationUnsupportedClientError": - return new PreviewAutomationUnsupportedClientError({ message: error.message }); + return new PreviewAutomationUnsupportedClientError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationTabNotFoundError": - return new PreviewAutomationTabNotFoundError({ message: error.message }); + return new PreviewAutomationTabNotFoundError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationTimeoutError": - return new PreviewAutomationTimeoutError({ message: error.message }); + return new PreviewAutomationTimeoutError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationControlInterruptedError": - return new PreviewAutomationControlInterruptedError({ message: error.message }); + return new PreviewAutomationControlInterruptedError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationInvalidSelectorError": { - const detail = - typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; return new PreviewAutomationInvalidSelectorError({ - message: error.message, - selector: - detail && "selector" in detail && typeof detail.selector === "string" - ? detail.selector - : "", + ...context, + ...remoteDiagnostics, }); } case "PreviewAutomationResultTooLargeError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + const maximumBytes = + detail && + "maximumBytes" in detail && + typeof detail.maximumBytes === "number" && + Number.isInteger(detail.maximumBytes) && + detail.maximumBytes > 0 + ? detail.maximumBytes + : undefined; return new PreviewAutomationResultTooLargeError({ - message: error.message, - maximumBytes: - detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" - ? detail.maximumBytes - : 64_000, + ...context, + ...remoteDiagnostics, + ...(maximumBytes === undefined ? {} : { maximumBytes }), }); } case "PreviewAutomationUnavailableError": - return new PreviewAutomationUnavailableError({ message: error.message }); + return new PreviewAutomationRemoteUnavailableError({ + ...context, + ...remoteDiagnostics, + }); default: return new PreviewAutomationExecutionError({ - message: error.message, - detail: error.detail, + ...context, + ...remoteDiagnostics, }); } }; @@ -148,13 +223,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); yield* Effect.forEach( toFail, - ({ deferred }) => - Deferred.fail( - deferred, - new PreviewAutomationUnavailableError({ - message: "The preview automation client disconnected.", - }), - ), + ({ deferred, context }) => + Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), { discard: true }, ); yield* Queue.shutdown(queue); @@ -228,10 +298,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { yield* Deferred.fail( pending.deferred, response.error - ? makeResponseError(response.error) - : new PreviewAutomationExecutionError({ - message: "Preview automation failed without an error payload.", - }), + ? classifyResponseError(pending.context, response.error) + : new PreviewAutomationMalformedResponseError(pending.context), ); } }); @@ -250,28 +318,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); 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.", + const disconnectedOwner = candidates[0]; + if (disconnectedOwner) { + return yield* new PreviewAutomationHostNotConnectedError({ + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: disconnectedOwner.clientId, }); } return yield* new PreviewAutomationNoFocusedOwnerError({ - message: "No desktop browser host is available for this thread.", + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, }); } const connection = current.clients.get(owner.clientId); if (!connection) { - return yield* new PreviewAutomationUnavailableError({ - message: "The browser host is not connected.", + return yield* new PreviewAutomationHostNotConnectedError({ + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: owner.clientId, }); } const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make(); - const requestId = yield* SynchronizedRef.modify(state, (next) => { + const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => { const requestId = `preview-${next.requestSequence}`; + const tabId = input.tabId ?? owner.tabId ?? undefined; + const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); + const context: PreviewAutomationRequestErrorContext = { + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: owner.clientId, + requestId, + ...(tabId === undefined ? {} : { tabId }), + timeoutMs, + ...selectorDiagnostics, + }; const pending = new Map(next.pending); - pending.set(requestId, { queue: connection.queue, deferred }); - return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; + pending.set(requestId, { queue: connection.queue, deferred, context }); + return [ + [requestId, context] as const, + { ...next, pending, requestSequence: next.requestSequence + 1 }, + ] as const; }); const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; @@ -283,24 +383,21 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const offered = yield* Queue.offer(connection.queue, { requestId, threadId: input.scope.threadId, - tabId: input.tabId ?? owner.tabId ?? undefined, + tabId: requestContext.tabId, operation: input.operation, input: input.input, timeoutMs, }); if (!offered) { - return yield* new PreviewAutomationUnavailableError({ - message: "The preview automation client is no longer accepting requests.", - }); + const completion = yield* Deferred.poll(deferred); + if (Option.isSome(completion)) { + return (yield* completion.value) as A; + } + return yield* new PreviewAutomationRequestQueueClosedError(requestContext); } const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); return yield* Option.match(result, { - onNone: () => - Effect.fail( - new PreviewAutomationTimeoutError({ - message: `Preview automation timed out after ${timeoutMs}ms.`, - }), - ), + onNone: () => Effect.fail(new PreviewAutomationTimeoutError(requestContext)), onSome: (value) => Effect.succeed(value as A), }); }); diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 110fc2415ad..d6b9f59ae8d 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { PreviewTabId } from "./preview.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; const BoundedUrl = Schema.String.check(Schema.isTrimmed()) .check( @@ -455,45 +456,205 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass

()( "PreviewAutomationNoFocusedOwnerError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationScopeErrorFields, + clientId: Schema.optional(TrimmedNonEmptyString), + requestId: Schema.optional(TrimmedNonEmptyString), + tabId: Schema.optional(PreviewTabId), + timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + ...PreviewAutomationOptionalRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = `No focused preview automation owner is available for ${this.operation} in thread ${this.threadId}.`; + return summary; + } +} export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass()( "PreviewAutomationUnsupportedClientError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation client ${this.clientId} does not support ${this.operation}.`; + } +} export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass()( "PreviewAutomationTabNotFoundError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = this.tabId + ? `Preview tab ${this.tabId} was not found for ${this.operation}.` + : `No active preview tab was found for ${this.operation}.`; + return summary; + } +} export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass()( "PreviewAutomationTimeoutError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationOptionalRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = `Preview automation ${this.operation} timed out after ${this.timeoutMs}ms.`; + return summary; + } +} export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass()( "PreviewAutomationControlInterruptedError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`; + } +} export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass()( "PreviewAutomationExecutionError", - { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} failed on client ${this.clientId}.`; + } +} export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass()( "PreviewAutomationInvalidSelectorError", - { message: Schema.String, selector: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])), + selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }, +) { + override get message(): string { + if (this.selectorKind !== undefined && this.selectorLength !== undefined) { + return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`; + } + return `Preview automation ${this.operation} received an invalid selector.`; + } +} export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()( "PreviewAutomationResultTooLargeError", - { message: Schema.String, maximumBytes: Schema.Int }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + maximumBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + }, +) { + override get message(): string { + const summary = + this.maximumBytes === undefined + ? `Preview automation ${this.operation} produced a result that is too large.` + : `Preview automation ${this.operation} produced a result larger than ${this.maximumBytes} bytes.`; + return summary; + } +} + +export class PreviewAutomationHostNotConnectedError extends Schema.TaggedErrorClass()( + "PreviewAutomationHostNotConnectedError", + { + ...PreviewAutomationScopeErrorFields, + clientId: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Preview automation host ${this.clientId} is not connected for ${this.operation}.`; + } +} + +export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass()( + "PreviewAutomationClientDisconnectedError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} disconnected during ${this.operation}.`; + } +} + +export class PreviewAutomationRequestQueueClosedError extends Schema.TaggedErrorClass()( + "PreviewAutomationRequestQueueClosedError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} stopped accepting ${this.operation} requests.`; + } +} + +export class PreviewAutomationRemoteUnavailableError extends Schema.TaggedErrorClass()( + "PreviewAutomationRemoteUnavailableError", + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} is unavailable on client ${this.clientId}.`; + } +} + +export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorClass()( + "PreviewAutomationMalformedResponseError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} returned a malformed response for ${this.operation}.`; + } +} export const PreviewAutomationError = Schema.Union([ PreviewAutomationUnavailableError, @@ -505,6 +666,11 @@ export const PreviewAutomationError = Schema.Union([ PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationResultTooLargeError, + PreviewAutomationHostNotConnectedError, + PreviewAutomationClientDisconnectedError, + PreviewAutomationRequestQueueClosedError, + PreviewAutomationRemoteUnavailableError, + PreviewAutomationMalformedResponseError, ]); export type PreviewAutomationError = typeof PreviewAutomationError.Type; From c8454ad88a8774034544f02d177fbebd0520f80a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:01:31 -0700 Subject: [PATCH 150/257] [codex] structure workspace search failures (#3352) Co-authored-by: codex --- .../workspace/WorkspaceSearchIndex.test.ts | 128 ++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 107 +++++++++++---- 2 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/workspace/WorkspaceSearchIndex.test.ts diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts new file mode 100644 index 00000000000..41ea90b9735 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -0,0 +1,128 @@ +import { FileFinder } from "@ff-labs/fff-node"; +import { afterEach, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it.effect("preserves unexpected FileFinder creation failures", () => + Effect.gen(function* () { + const cause = new Error("native initialization failed"); + vi.spyOn(FileFinder, "create").mockImplementationOnce(() => { + throw cause; + }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexCreateFailed", + cwd: "/workspace/project", + reason: "FileFinder.create threw unexpectedly.", + cause, + }); + }), +); + +it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () => + Effect.gen(function* () { + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ + ok: false, + error: "native index rejected the directory", + }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexCreateFailed", + cwd: "/workspace/project", + reason: "native index rejected the directory", + }); + expect(error.cause).toBeUndefined(); + }), +); + +it.effect("preserves search and refresh failures with operation context", () => + Effect.scoped( + Effect.gen(function* () { + const searchCause = new Error("native search failed"); + const refreshCause = new Error("native scan failed"); + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => { + throw searchCause; + }), + scanFiles: vi.fn(() => { + throw refreshCause; + }), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const query = "authorization: Bearer secret-token"; + const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const refreshError = yield* Effect.flip(searchIndex.refresh()); + + expect(searchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 4, + reason: "FileFinder.mixedSearch threw unexpectedly.", + cause: searchCause, + }); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(refreshError).toMatchObject({ + _tag: "WorkspaceSearchIndexRefreshFailed", + cwd: "/workspace/project", + reason: "FileFinder.scanFiles threw unexpectedly.", + cause: refreshCause, + }); + }), + ), +); + +it.effect("keeps returned search diagnostics out of the cause chain", () => + Effect.scoped( + Effect.gen(function* () { + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), + scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const query = "authorization: Bearer secret-token"; + const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const refreshError = yield* Effect.flip(searchIndex.refresh()); + + expect(searchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 4, + reason: "native query rejected", + }); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(searchError.cause).toBeUndefined(); + expect(refreshError).toMatchObject({ + _tag: "WorkspaceSearchIndexRefreshFailed", + cwd: "/workspace/project", + reason: "native refresh rejected", + }); + expect(refreshError.cause).toBeUndefined(); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index fcacf3caf13..2b043e05c0e 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -23,10 +23,11 @@ export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass): ProjectEn } const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { - const result = FileFinder.create({ - basePath: cwd, - disableMmapCache: true, - disableContentIndexing: true, - aiMode: false, - enableFsRootScanning: true, - enableHomeDirScanning: true, + const result = yield* Effect.try({ + try: () => + FileFinder.create({ + basePath: cwd, + disableMmapCache: true, + disableContentIndexing: true, + aiMode: false, + enableFsRootScanning: true, + enableHomeDirScanning: true, + }), + catch: (cause) => + new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: "FileFinder.create threw unexpectedly.", + cause, + }), }); if (result.ok) return result.value; - return yield* new WorkspaceSearchIndexCreateFailed({ cwd, reason: result.error }); + return yield* new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: result.error, + }); }); -const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( - cwd: string, - finder: FileFinder, -) { - yield* Effect.sync(() => finder.isScanning()).pipe( +const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => + Effect.try({ + try: () => finder.isScanning(), + catch: onFailure, + }).pipe( Effect.repeat({ while: (scanning) => scanning, schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), @@ -179,22 +196,46 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( orElse: () => new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), }), + Effect.withSpan("WorkspaceSearchIndex.waitForScan"), ); -}); export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy()), ); - yield* waitForScan(cwd, finder); + yield* waitForScan( + cwd, + finder, + (cause) => + new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: "FileFinder.isScanning threw while creating the index.", + cause, + }), + ); const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( query: string, pageSize: number, ) { - const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); + const result = yield* Effect.try({ + try: () => finder.mixedSearch(query, { pageSize }), + catch: (cause) => + new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: query.length, + pageSize, + reason: "FileFinder.mixedSearch threw unexpectedly.", + cause, + }), + }); if (!result.ok) { - return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); + return yield* new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: query.length, + pageSize, + reason: result.error, + }); } return result.value; }); @@ -202,11 +243,31 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( "WorkspaceSearchIndex.refresh", )(function* () { - const result = yield* Effect.sync(() => finder.scanFiles()); + const result = yield* Effect.try({ + try: () => finder.scanFiles(), + catch: (cause) => + new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: "FileFinder.scanFiles threw unexpectedly.", + cause, + }), + }); if (!result.ok) { - return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); + return yield* new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: result.error, + }); } - yield* waitForScan(cwd, finder); + yield* waitForScan( + cwd, + finder, + (cause) => + new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: "FileFinder.isScanning threw while refreshing the index.", + cause, + }), + ); }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( From fdc1e8b3a26d9a2b09e9b814e1a02be77ebb68a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:02:16 -0700 Subject: [PATCH 151/257] [codex] Structure pairing grant failures (#3386) Co-authored-by: codex --- .../server/src/auth/PairingGrantStore.test.ts | 47 +++++ apps/server/src/auth/PairingGrantStore.ts | 160 ++++++++++++------ 2 files changed, 159 insertions(+), 48 deletions(-) diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index b3c9b30f643..53b1a7e7929 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -3,9 +3,12 @@ import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; +import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; @@ -33,6 +36,26 @@ const makePairingGrantStoreLayer = ( Layer.provide(makeServerConfigLayer(overrides)), ); +const makePairingGrantStoreTestLayer = ( + overrides: Partial, +) => + Layer.effect(PairingGrantStore.PairingGrantStore, PairingGrantStore.make).pipe( + Layer.provide( + Layer.succeed( + AuthPairingLinks.AuthPairingLinkRepository, + AuthPairingLinks.AuthPairingLinkRepository.of({ + create: () => Effect.void, + consumeAvailable: () => Effect.succeed(Option.none()), + listActive: () => Effect.succeed([]), + revoke: () => Effect.succeed(false), + getByCredential: () => Effect.succeed(Option.none()), + ...overrides, + }), + ), + ), + Layer.provide(makeServerConfigLayer()), + ); + it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { it.effect("issues pairing tokens in a short manual-entry format", () => Effect.gen(function* () { @@ -186,4 +209,28 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError"); }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); + + it.effect("identifies consume-available failures and preserves their cause", () => { + const repositoryFailure = new PersistenceSqlError({ + operation: "consume-pairing-link", + detail: "Database unavailable", + cause: new Error("database unavailable"), + }); + + return Effect.gen(function* () { + const pairingGrants = yield* PairingGrantStore.PairingGrantStore; + const error = yield* Effect.flip(pairingGrants.consume("credential")); + + if (error._tag !== "BootstrapCredentialConsumeAvailableError") { + return yield* Effect.die(error); + } + expect(error.cause).toBe(repositoryFailure); + }).pipe( + Effect.provide( + makePairingGrantStoreTestLayer({ + consumeAvailable: () => Effect.fail(repositoryFailure), + }), + ), + ); + }); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 8a7a4d2e40f..7a8fb9477cf 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -88,22 +88,38 @@ export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass()( "PairingLinkRevokeError", { + pairingLinkId: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to revoke pairing link."; + return `Failed to revoke pairing link '${this.pairingLinkId}'.`; } } export class PairingCredentialIssueError extends Schema.TaggedErrorClass()( "PairingCredentialIssueError", { + pairingLinkId: Schema.String, + subject: Schema.String, + label: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to issue pairing credential."; + return `Failed to issue pairing credential '${this.pairingLinkId}' for '${this.subject}'.`; + } +} + +export class PairingCredentialRandomGenerationError extends Schema.TaggedErrorClass()( + "PairingCredentialRandomGenerationError", + { + operation: Schema.Literals(["generate-id", "generate-token"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to generate pairing credential data during '${this.operation}'.`; } } @@ -118,11 +134,36 @@ export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass()( + "BootstrapCredentialConsumeAvailableError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to atomically consume an available bootstrap credential."; + } +} + +export class BootstrapCredentialLookupError extends Schema.TaggedErrorClass()( + "BootstrapCredentialLookupError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to look up bootstrap credential state."; + } +} + export const BootstrapCredentialInternalError = Schema.Union([ ActivePairingLinksLoadError, PairingLinkRevokeError, PairingCredentialIssueError, + PairingCredentialRandomGenerationError, BootstrapCredentialConsumeError, + BootstrapCredentialConsumeAvailableError, + BootstrapCredentialLookupError, ]); export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type; export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError); @@ -207,7 +248,14 @@ export const make = Effect.gen(function* () { const generatePairingToken = Effect.gen(function* () { let credential = ""; while (credential.length < PAIRING_TOKEN_LENGTH) { - const bytes = yield* crypto.randomBytes(PAIRING_TOKEN_LENGTH); + const bytes = yield* crypto + .randomBytes(PAIRING_TOKEN_LENGTH) + .pipe( + Effect.mapError( + (cause) => + new PairingCredentialRandomGenerationError({ operation: "generate-token", cause }), + ), + ); for (const byte of bytes) { if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) { continue; @@ -287,58 +335,73 @@ export const make = Effect.gen(function* () { const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")( function* (id) { const revokedAt = yield* DateTime.now; - const revoked = yield* pairingLinks.revoke({ - id, - revokedAt, - }); + const revoked = yield* pairingLinks + .revoke({ + id, + revokedAt, + }) + .pipe(Effect.mapError((cause) => new PairingLinkRevokeError({ pairingLinkId: id, cause }))); if (revoked) { yield* emitRemoved(id); } return revoked; }, - Effect.mapError((cause) => new PairingLinkRevokeError({ cause })), ); const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( "PairingGrantStore.issueOneTimeToken", - )( - function* (input) { - const id = yield* crypto.randomUUIDv4; - const credential = yield* generatePairingToken; - const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; - const now = yield* DateTime.now; - const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); - const issued: IssuedBootstrapCredential = { - id, - credential, - ...(input?.label ? { label: input.label } : {}), - ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), - expiresAt, - }; - yield* pairingLinks.create({ + )(function* (input) { + const id = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new PairingCredentialRandomGenerationError({ operation: "generate-id", cause }), + ), + ); + const credential = yield* generatePairingToken; + const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); + const issued: IssuedBootstrapCredential = { + id, + credential, + ...(input?.label ? { label: input.label } : {}), + ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), + expiresAt, + }; + const subject = input?.subject ?? "one-time-token"; + yield* pairingLinks + .create({ id, credential, method: "one-time-token", scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", + subject, label: input?.label ?? null, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, createdAt: now, expiresAt: expiresAt, - }); - yield* emitUpsert({ - id, - credential, - scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", - ...(input?.label ? { label: input.label } : {}), - createdAt: now, - expiresAt, - }); - return issued; - }, - Effect.mapError((cause) => new PairingCredentialIssueError({ cause })), - ); + }) + .pipe( + Effect.mapError( + (cause) => + new PairingCredentialIssueError({ + pairingLinkId: id, + subject, + ...(input?.label ? { label: input.label } : {}), + cause, + }), + ), + ); + yield* emitUpsert({ + id, + credential, + scopes: input?.scopes ?? AuthStandardClientScopes, + subject: input?.subject ?? "one-time-token", + ...(input?.label ? { label: input.label } : {}), + createdAt: now, + expiresAt, + }); + return issued; + }); const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( function* (credential, input) { @@ -420,12 +483,14 @@ export const make = Effect.gen(function* () { return yield* seededResult.error; } - const consumed = yield* pairingLinks.consumeAvailable({ - credential, - proofKeyThumbprint: input?.proofKeyThumbprint ?? null, - consumedAt: now, - now, - }); + const consumed = yield* pairingLinks + .consumeAvailable({ + credential, + proofKeyThumbprint: input?.proofKeyThumbprint ?? null, + consumedAt: now, + now, + }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause }))); if (Option.isSome(consumed)) { yield* emitRemoved(consumed.value.id); @@ -441,7 +506,9 @@ export const make = Effect.gen(function* () { } satisfies BootstrapGrant; } - const matching = yield* pairingLinks.getByCredential({ credential }); + const matching = yield* pairingLinks + .getByCredential({ credential }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); if (Option.isNone(matching)) { return yield* new UnknownBootstrapCredentialError({}); } @@ -467,9 +534,6 @@ export const make = Effect.gen(function* () { return yield* new UnavailableBootstrapCredentialError({}); }, - Effect.mapError((cause) => - isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }), - ), ); return PairingGrantStore.of({ From a1b727f62d56edd0e43c1d0a74142626fc7673c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:02:59 -0700 Subject: [PATCH 152/257] [codex] Structure relay device persistence errors (#3344) Co-authored-by: codex --- infra/relay/src/agentActivity/Devices.test.ts | 78 +++++ infra/relay/src/agentActivity/Devices.ts | 302 +++++++++++------- 2 files changed, 261 insertions(+), 119 deletions(-) diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index bcc627d8f90..553899da178 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -223,4 +223,82 @@ describe("Devices", () => { Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), ); }); + + it.effect("identifies the failed device registration stage", () => { + const cause = new Error("push-token claim failed"); + const fakeDb = { + update: () => ({ + set: (values: Record) => ({ + where: () => ("pushToken" in values ? Effect.fail(cause) : Effect.void), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const error = yield* devices.register({ userId: "user-2", registration }).pipe(Effect.flip); + + expect(error).toMatchObject({ + userId: "user-2", + deviceId: "device-1", + stage: "claim-push-token", + }); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Failed to persist mobile device registration for user-2/device-1 during claim-push-token.", + ); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); + + it.effect("identifies the failed device unregistration stage", () => { + const cause = new Error("live activity delete failed"); + const fakeDb = { + delete: (table: unknown) => ({ + where: () => (table === relayLiveActivities ? Effect.fail(cause) : Effect.void), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const error = yield* devices + .unregister({ userId: "user-2", deviceId: "device-1" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + userId: "user-2", + deviceId: "device-1", + stage: "delete-live-activity", + }); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Failed to unregister mobile device user-2/device-1 during delete-live-activity.", + ); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); + + it.effect("attaches the user to device list failures", () => { + const cause = new Error("device list failed"); + const fakeDb = { + select: () => ({ + from: () => ({ + where: () => Effect.fail(cause), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const error = yield* devices.listForUser({ userId: "user-2" }).pipe(Effect.flip); + + expect(error).toMatchObject({ userId: "user-2" }); + expect(error.cause).toBe(cause); + expect(error.message).toBe("Failed to list mobile devices for user-2."); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); }); diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 973c430832c..86e3564d5be 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -15,28 +15,41 @@ import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.t export class DeviceRegistrationPersistenceError extends Schema.TaggedErrorClass()( "DeviceRegistrationPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + deviceId: Schema.String, + stage: Schema.Literals(["claim-push-token", "claim-push-to-start-token", "upsert-device"]), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist mobile device registration"; + return `Failed to persist mobile device registration for ${this.userId}/${this.deviceId} during ${this.stage}.`; } } export class DeviceUnregistrationPersistenceError extends Schema.TaggedErrorClass()( "DeviceUnregistrationPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + deviceId: Schema.String, + stage: Schema.Literals(["delete-live-activity", "delete-device"]), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to unregister mobile device"; + return `Failed to unregister mobile device ${this.userId}/${this.deviceId} during ${this.stage}.`; } } export class DeviceListPersistenceError extends Schema.TaggedErrorClass()( "DeviceListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list mobile devices"; + return `Failed to list mobile devices for ${this.userId}.`; } } @@ -61,130 +74,181 @@ export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return Devices.of({ - register: Effect.fn("relay.devices.register")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.mobile.device_id": input.registration.deviceId, - }); - const updatedAt = DateTime.formatIso(yield* DateTime.now); - const registration = input.registration; + register: Effect.fn("relay.devices.register")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.registration.deviceId, + }); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + const registration = input.registration; - yield* Effect.all( - [ - registration.pushToken - ? db - .update(relayMobileDevices) - .set({ pushToken: null, updatedAt }) - .where(eq(relayMobileDevices.pushToken, registration.pushToken)) - : Effect.void, - registration.pushToStartToken - ? db - .update(relayMobileDevices) - .set({ pushToStartToken: null, updatedAt }) - .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) - : Effect.void, - ], - { concurrency: 2, discard: true }, - ); + yield* Effect.all( + [ + registration.pushToken + ? db + .update(relayMobileDevices) + .set({ pushToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToken, registration.pushToken)) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-push-token", + cause, + }), + ), + ) + : Effect.void, + registration.pushToStartToken + ? db + .update(relayMobileDevices) + .set({ pushToStartToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-push-to-start-token", + cause, + }), + ), + ) + : Effect.void, + ], + { discard: true }, + ); - yield* db - .insert(relayMobileDevices) - .values({ - userId: input.userId, - deviceId: registration.deviceId, - label: registration.label, + yield* db + .insert(relayMobileDevices) + .values({ + userId: input.userId, + deviceId: registration.deviceId, + label: registration.label, + platform: registration.platform, + iosMajorVersion: registration.iosMajorVersion, + appVersion: registration.appVersion ?? null, + pushToken: registration.pushToken ?? null, + pushToStartToken: registration.pushToStartToken ?? null, + preferencesJson: registration.preferences, + createdAt: updatedAt, + updatedAt, + }) + .onConflictDoUpdate({ + target: [relayMobileDevices.userId, relayMobileDevices.deviceId], + set: { platform: registration.platform, + label: registration.label, iosMajorVersion: registration.iosMajorVersion, appVersion: registration.appVersion ?? null, - pushToken: registration.pushToken ?? null, - pushToStartToken: registration.pushToStartToken ?? null, - preferencesJson: registration.preferences, - createdAt: updatedAt, - updatedAt, - }) - .onConflictDoUpdate({ - target: [relayMobileDevices.userId, relayMobileDevices.deviceId], - set: { - platform: registration.platform, - label: registration.label, - iosMajorVersion: registration.iosMajorVersion, - appVersion: registration.appVersion ?? null, - pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, - pushToStartToken: sql`coalesce( + pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, + pushToStartToken: sql`coalesce( excluded.push_to_start_token, ${relayMobileDevices.pushToStartToken} )`, - preferencesJson: registration.preferences, - updatedAt, - }, - }); - }, - Effect.mapError((cause) => new DeviceRegistrationPersistenceError({ cause })), - ), - unregister: Effect.fn("relay.devices.unregister")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.mobile.device_id": input.deviceId, - }); - yield* Effect.all( - [ - db - .delete(relayLiveActivities) - .where( - and( - eq(relayLiveActivities.userId, input.userId), - eq(relayLiveActivities.deviceId, input.deviceId), - ), + preferencesJson: registration.preferences, + updatedAt, + }, + }) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "upsert-device", + cause, + }), + ), + ); + }), + unregister: Effect.fn("relay.devices.unregister")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + }); + yield* Effect.all( + [ + db + .delete(relayLiveActivities) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), ), - db - .delete(relayMobileDevices) - .where( - and( - eq(relayMobileDevices.userId, input.userId), - eq(relayMobileDevices.deviceId, input.deviceId), - ), + ) + .pipe( + Effect.mapError( + (cause) => + new DeviceUnregistrationPersistenceError({ + userId: input.userId, + deviceId: input.deviceId, + stage: "delete-live-activity", + cause, + }), ), - ], - { concurrency: 2, discard: true }, + ), + db + .delete(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new DeviceUnregistrationPersistenceError({ + userId: input.userId, + deviceId: input.deviceId, + stage: "delete-device", + cause, + }), + ), + ), + ], + { discard: true }, + ); + }), + listForUser: Effect.fn("relay.devices.listForUser")(function* (input) { + const rows = yield* db + .select({ + deviceId: relayMobileDevices.deviceId, + label: relayMobileDevices.label, + platform: relayMobileDevices.platform, + iosMajorVersion: relayMobileDevices.iosMajorVersion, + appVersion: relayMobileDevices.appVersion, + preferences: relayMobileDevices.preferencesJson, + updatedAt: relayMobileDevices.updatedAt, + }) + .from(relayMobileDevices) + .where(eq(relayMobileDevices.userId, input.userId)) + .pipe( + Effect.mapError( + (cause) => new DeviceListPersistenceError({ userId: input.userId, cause }), + ), ); - }, - Effect.mapError((cause) => new DeviceUnregistrationPersistenceError({ cause })), - ), - listForUser: Effect.fn("relay.devices.listForUser")( - function* (input) { - const rows = yield* db - .select({ - deviceId: relayMobileDevices.deviceId, - label: relayMobileDevices.label, - platform: relayMobileDevices.platform, - iosMajorVersion: relayMobileDevices.iosMajorVersion, - appVersion: relayMobileDevices.appVersion, - preferences: relayMobileDevices.preferencesJson, - updatedAt: relayMobileDevices.updatedAt, - }) - .from(relayMobileDevices) - .where(eq(relayMobileDevices.userId, input.userId)); - return rows.map((row) => ({ - deviceId: row.deviceId, - label: row.label, - platform: row.platform, - iosMajorVersion: row.iosMajorVersion, - appVersion: row.appVersion, - notifications: { - enabled: row.preferences.notificationsEnabled, - notifyOnApproval: row.preferences.notifyOnApproval, - notifyOnInput: row.preferences.notifyOnInput, - notifyOnCompletion: row.preferences.notifyOnCompletion, - notifyOnFailure: row.preferences.notifyOnFailure, - }, - liveActivities: { - enabled: row.preferences.liveActivitiesEnabled, - }, - updatedAt: row.updatedAt, - })); - }, - Effect.mapError((cause) => new DeviceListPersistenceError({ cause })), - ), + return rows.map((row) => ({ + deviceId: row.deviceId, + label: row.label, + platform: row.platform, + iosMajorVersion: row.iosMajorVersion, + appVersion: row.appVersion, + notifications: { + enabled: row.preferences.notificationsEnabled, + notifyOnApproval: row.preferences.notifyOnApproval, + notifyOnInput: row.preferences.notifyOnInput, + notifyOnCompletion: row.preferences.notifyOnCompletion, + notifyOnFailure: row.preferences.notifyOnFailure, + }, + liveActivities: { + enabled: row.preferences.liveActivitiesEnabled, + }, + updatedAt: row.updatedAt, + })); + }), }); }); From c9159b67b30340e12f75005e34faa7d1aca0f231 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:03:43 -0700 Subject: [PATCH 153/257] [codex] Structure APNs client errors (#3318) Co-authored-by: codex --- .../src/agentActivity/ApnsClient.test.ts | 123 ++++++++++++++- infra/relay/src/agentActivity/ApnsClient.ts | 143 ++++++++++++++---- 2 files changed, 233 insertions(+), 33 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 1d327aa945b..bb557376862 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -1,13 +1,22 @@ +import * as NodeCrypto from "node:crypto"; + +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/unstable/http"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; -import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ApnsCredentials } from "../Config.ts"; import * as ApnsClient from "./ApnsClient.ts"; +const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError); +const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); + const TestLayer = ApnsClient.layer.pipe( Layer.provide( Layer.succeed( @@ -137,4 +146,112 @@ describe("ApnsClient", () => { }); }).pipe(Effect.provide(TestLayer)), ); + + it.effect("preserves JWT signing context and the crypto cause", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }, + }); + const error = yield* Effect.flip( + apns.sendPushNotificationRequest({ + credentials: { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make("not-a-private-key"), + bundleId: "com.t3tools.test", + environment: "sandbox", + }, + request, + issuedAtUnixSeconds: 123, + }), + ); + + expect(isApnsJwtSigningError(error)).toBe(true); + if (!isApnsJwtSigningError(error)) { + return yield* Effect.die("expected APNs JWT signing error"); + } + expect(error).toMatchObject({ + teamId: "team-1", + keyId: "key-1", + issuedAtUnixSeconds: 123, + cause: expect.any(Error), + message: "Failed to sign APNs JWT for key key-1.", + }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves APNs request context and the HTTP cause", () => { + const httpCause = new Error("network unavailable"); + const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const credentials = { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make(privateKey), + bundleId: "com.t3tools.test", + environment: "sandbox", + } satisfies ApnsCredentials; + const failingHttpClient = HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: httpCause }), + }), + ), + ); + const layer = ApnsClient.layer.pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), + ); + + return Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "long-push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }, + }); + const error = yield* Effect.flip( + apns.sendPushNotificationRequest({ + credentials, + request, + issuedAtUnixSeconds: 123, + }), + ); + + expect(isApnsHttpRequestError(error)).toBe(true); + if (!isApnsHttpRequestError(error)) { + return yield* Effect.die("expected APNs HTTP request error"); + } + expect(error).toMatchObject({ + requestKind: "push-notification", + event: null, + environment: "sandbox", + bundleId: "com.t3tools.test", + tokenSuffix: "sh-token", + stage: "send", + status: null, + message: "APNs push-notification request failed during send in sandbox.", + }); + expect(error.cause).toBeInstanceOf(HttpClientError.HttpClientError); + expect((error.cause as HttpClientError.HttpClientError).reason).toMatchObject({ + _tag: "TransportError", + cause: httpCause, + }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 01a3d04bd62..1ac218cdd3c 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -11,14 +11,17 @@ import * as Schema from "effect/Schema"; 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 { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts"; import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; const STALE_AFTER_SECONDS = 2 * 60; const DISMISS_AFTER_SECONDS = 5 * 60; -export type ApnsLiveActivityEvent = "start" | "update" | "end"; +const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); +export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type; + +const ApnsRequestKindSchema = Schema.Literals(["live-activity", "push-notification"]); interface ApnsLiveActivityRequest { readonly token: string; @@ -42,41 +45,48 @@ export interface ApnsDeliveryResult { export class ApnsJwtEncodingError extends Schema.TaggedErrorClass()( "ApnsJwtEncodingError", - { cause: Schema.Defect() }, + { + component: Schema.Literals(["header", "payload"]), + teamId: Schema.String, + keyId: Schema.String, + issuedAtUnixSeconds: Schema.Number, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to encode APNs JWT."; + return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; } } export class ApnsJwtSigningError extends Schema.TaggedErrorClass()( "ApnsJwtSigningError", - { cause: Schema.Defect() }, -) { - override get message(): string { - return "Failed to sign APNs JWT."; - } -} - -export class ApnsHttpRequestError extends Schema.TaggedErrorClass()( - "ApnsHttpRequestError", { + teamId: Schema.String, + keyId: Schema.String, + issuedAtUnixSeconds: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return "APNs HTTP request failed."; + return `Failed to sign APNs JWT for key ${this.keyId}.`; } } -export class ApnsInvalidResponseError extends Schema.TaggedErrorClass()( - "ApnsInvalidResponseError", +export class ApnsHttpRequestError extends Schema.TaggedErrorClass()( + "ApnsHttpRequestError", { + requestKind: ApnsRequestKindSchema, + event: Schema.NullOr(ApnsLiveActivityEventSchema), + environment: ApnsEnvironmentSchema, + bundleId: Schema.String, + tokenSuffix: Schema.String, + stage: Schema.Literals(["send", "read-response"]), + status: Schema.NullOr(Schema.Number), cause: Schema.Defect(), }, ) { override get message(): string { - return "APNs returned an invalid response."; + return `APNs ${this.requestKind} request failed during ${this.stage} in ${this.environment}.`; } } @@ -84,7 +94,6 @@ export const ApnsError = Schema.Union([ ApnsJwtEncodingError, ApnsJwtSigningError, ApnsHttpRequestError, - ApnsInvalidResponseError, ]); export type ApnsError = typeof ApnsError.Type; @@ -113,18 +122,38 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect( ); const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { - readonly teamId: RelayConfiguration.ApnsCredentials["teamId"]; - readonly keyId: RelayConfiguration.ApnsCredentials["keyId"]; - readonly privateKey: RelayConfiguration.ApnsCredentials["privateKey"]; + readonly teamId: ApnsCredentials["teamId"]; + readonly keyId: ApnsCredentials["keyId"]; + readonly privateKey: ApnsCredentials["privateKey"]; readonly issuedAtUnixSeconds: number; }) { const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( - Effect.mapError((cause) => new ApnsJwtEncodingError({ cause })), + Effect.mapError( + (cause) => + new ApnsJwtEncodingError({ + component: "header", + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + ), ); const payloadJson = yield* encodeApnsJwtPayloadJson({ iss: input.teamId, iat: input.issuedAtUnixSeconds, - }).pipe(Effect.mapError((cause) => new ApnsJwtEncodingError({ cause }))); + }).pipe( + Effect.mapError( + (cause) => + new ApnsJwtEncodingError({ + component: "payload", + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + ), + ); const privateKey = Redacted.value(input.privateKey); const header = Encoding.encodeBase64Url(headerJson); @@ -141,7 +170,13 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { }); return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; }, - catch: (cause) => new ApnsJwtSigningError({ cause }), + catch: (cause) => + new ApnsJwtSigningError({ + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), }); }); @@ -251,12 +286,12 @@ export class ApnsClient extends Context.Service< readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; readonly makePushNotificationRequest: typeof makePushNotificationRequest; readonly sendLiveActivityRequest: (input: { - readonly credentials: RelayConfiguration.ApnsCredentials; + readonly credentials: ApnsCredentials; readonly request: ApnsLiveActivityRequest; readonly issuedAtUnixSeconds: number; }) => Effect.Effect; readonly sendPushNotificationRequest: (input: { - readonly credentials: RelayConfiguration.ApnsCredentials; + readonly credentials: ApnsCredentials; readonly request: ApnsPushNotificationRequest; readonly issuedAtUnixSeconds: number; }) => Effect.Effect; @@ -287,10 +322,34 @@ export const make = Effect.gen(function* () { }), HttpClientRequest.bodyJson(input.request.payload), Effect.flatMap(httpClient.execute), - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + Effect.mapError( + (cause) => + new ApnsHttpRequestError({ + requestKind: "live-activity", + event: input.request.event, + environment: input.credentials.environment, + bundleId: input.credentials.bundleId, + tokenSuffix: input.request.token.slice(-8), + stage: "send", + status: null, + cause, + }), + ), ); const responseText = yield* response.text.pipe( - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + Effect.mapError( + (cause) => + new ApnsHttpRequestError({ + requestKind: "live-activity", + event: input.request.event, + environment: input.credentials.environment, + bundleId: input.credentials.bundleId, + tokenSuffix: input.request.token.slice(-8), + stage: "read-response", + status: response.status, + cause, + }), + ), ); const reason = apnsReasonFromBody(responseText); return { @@ -323,10 +382,34 @@ export const make = Effect.gen(function* () { }), HttpClientRequest.bodyJson(input.request.payload), Effect.flatMap(httpClient.execute), - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + Effect.mapError( + (cause) => + new ApnsHttpRequestError({ + requestKind: "push-notification", + event: null, + environment: input.credentials.environment, + bundleId: input.credentials.bundleId, + tokenSuffix: input.request.token.slice(-8), + stage: "send", + status: null, + cause, + }), + ), ); const responseText = yield* response.text.pipe( - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + Effect.mapError( + (cause) => + new ApnsHttpRequestError({ + requestKind: "push-notification", + event: null, + environment: input.credentials.environment, + bundleId: input.credentials.bundleId, + tokenSuffix: input.request.token.slice(-8), + stage: "read-response", + status: response.status, + cause, + }), + ), ); const reason = apnsReasonFromBody(responseText); return { From 5f860439e9c981c29fc4385ad505ffebfbb63343 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:04:27 -0700 Subject: [PATCH 154/257] [codex] Structure APNs delivery job errors (#3322) Co-authored-by: codex --- .../src/agentActivity/ApnsDeliveries.test.ts | 16 ++ .../relay/src/agentActivity/ApnsDeliveries.ts | 25 ++- .../agentActivity/apnsDeliveryJobs.test.ts | 41 +++- .../src/agentActivity/apnsDeliveryJobs.ts | 186 ++++++++++++++---- 4 files changed, 221 insertions(+), 47 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 1497b6a73f4..207f4b21417 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -611,6 +611,22 @@ describe("ApnsDeliveries", () => { }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); }); + it.effect("preserves the schema cause for invalid queue payloads", () => { + const attempts: Array = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const error = yield* Effect.flip(deliveries.processSignedJob({ invalid: true })); + + expect(error).toMatchObject({ + _tag: "ApnsDeliveryJobQueuePayloadInvalid", + receivedType: "object", + message: "Invalid APNs delivery queue job with object payload.", + }); + expect(error.cause).toMatchObject({ _tag: "SchemaError" }); + }).pipe(Effect.provide(makeLayer({ attempts }))); + }); + it.effect("processes signed jobs through APNs and records attempts", () => { const attempts: Array = []; const payload = makeApnsDeliveryJobPayload({ diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index e0b652823ba..6c714d1d56d 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -640,7 +640,13 @@ export const make = Effect.gen(function* () { "relay.apns_deliveries.process_signed_job", )(function* (body) { const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( - Effect.mapError(() => new ApnsDeliveryJobQueuePayloadInvalid()), + Effect.mapError( + (cause) => + new ApnsDeliveryJobQueuePayloadInvalid({ + receivedType: Array.isArray(body) ? "array" : body === null ? "null" : typeof body, + cause, + }), + ), ); const now = yield* DateTime.now; const payload = verifySignedApnsDeliveryJob({ @@ -661,7 +667,14 @@ export const make = Effect.gen(function* () { case "live_activity_start": case "live_activity_update": if (payload.aggregate === null) { - return Effect.fail(new ApnsDeliveryJobLiveActivityAggregateMissing()); + return Effect.fail( + new ApnsDeliveryJobLiveActivityAggregateMissing({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }), + ); } return sendLiveActivity({ target: { @@ -686,7 +699,13 @@ export const make = Effect.gen(function* () { }); case "push_notification": if (payload.notification === null) { - return Effect.fail(new ApnsDeliveryJobPushNotificationMissing()); + return Effect.fail( + new ApnsDeliveryJobPushNotificationMissing({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }), + ); } return sendPushNotification({ target: { diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts index d65587f0d19..a0a45b9ed72 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts @@ -70,6 +70,11 @@ describe("apnsDeliveryJobs", () => { expect(result).toMatchObject({ _tag: "ApnsDeliveryJobSignatureInvalid", + jobId: "job-1", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + message: "Invalid signature for APNs delivery job job-1.", }); }); @@ -94,7 +99,11 @@ describe("apnsDeliveryJobs", () => { expect(result).toMatchObject({ _tag: "ApnsDeliveryJobLiveActivityAggregateMissing", - message: "Live Activity start/update jobs require an aggregate.", + jobId: "job-start-invalid", + kind: "live_activity_start", + userId: "user-1", + deviceId: "device-1", + message: "APNs live activity start job job-start-invalid requires an aggregate.", }); }); @@ -120,7 +129,10 @@ describe("apnsDeliveryJobs", () => { expect(result).toMatchObject({ _tag: "ApnsDeliveryJobPushNotificationAggregateUnexpected", - message: "Push notification jobs must not carry aggregate state.", + jobId: "job-push-invalid", + userId: "user-1", + deviceId: "device-1", + message: "APNs push notification job job-push-invalid must not carry aggregate state.", }); }); @@ -195,7 +207,12 @@ describe("apnsDeliveryJobs", () => { }), ).toMatchObject({ _tag: "ApnsDeliveryJobCreatedAtInvalid", - message: "Invalid APNs delivery job creation time.", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "not-a-date", + message: "APNs delivery job job-window has invalid creation time not-a-date.", }); expect( verifySignedApnsDeliveryJob({ @@ -205,7 +222,14 @@ describe("apnsDeliveryJobs", () => { }), ).toMatchObject({ _tag: "ApnsDeliveryJobTimeWindowInvalid", - message: "Invalid APNs delivery job time window.", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-24T23:59:59.000Z", + message: + "APNs delivery job job-window has invalid time window 2026-05-25T00:00:00.000Z to 2026-05-24T23:59:59.000Z.", }); expect( verifySignedApnsDeliveryJob({ @@ -215,7 +239,14 @@ describe("apnsDeliveryJobs", () => { }), ).toMatchObject({ _tag: "ApnsDeliveryJobTimeWindowTooLong", - message: "APNs delivery job time window is too long.", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:10:01.000Z", + message: + "APNs delivery job job-window time window 2026-05-25T00:00:00.000Z to 2026-05-25T00:10:01.000Z is too long.", }); }); }); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index 6e493943ab4..2af61085eab 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -10,12 +10,27 @@ import * as Schema from "effect/Schema"; const MAX_JOB_AGE_MS = 10 * 60 * 1_000; export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256"; -const ApnsDeliveryKind = Schema.Literals([ +const ApnsDeliveryKindSchema = Schema.Literals([ "live_activity_start", "live_activity_update", "live_activity_end", "push_notification", ]); +const LiveActivityStartOrUpdateKindSchema = Schema.Literals([ + "live_activity_start", + "live_activity_update", +]); +const LiveActivityKindSchema = Schema.Literals([ + "live_activity_start", + "live_activity_update", + "live_activity_end", +]); + +const ApnsDeliveryJobContext = { + jobId: Schema.String, + userId: Schema.String, + deviceId: Schema.String, +}; export const ApnsNotificationPayload = Schema.Struct({ title: Schema.String, @@ -29,7 +44,7 @@ export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; export const ApnsDeliveryJobPayload = Schema.Struct({ version: Schema.Literal(1), jobId: Schema.String, - kind: ApnsDeliveryKind, + kind: ApnsDeliveryKindSchema, target: Schema.Struct({ userId: Schema.String, deviceId: Schema.String, @@ -51,91 +66,121 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobQueuePayloadInvalid", - {}, + { + receivedType: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Invalid APNs delivery queue job."; + return `Invalid APNs delivery queue job with ${this.receivedType} payload.`; } } export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass()( "ApnsDeliveryJobLiveActivityAggregateMissing", - {}, + { + ...ApnsDeliveryJobContext, + kind: LiveActivityStartOrUpdateKindSchema, + }, ) { override get message(): string { - return "Live Activity start/update jobs require an aggregate."; + return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} requires an aggregate.`; } } export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass()( "ApnsDeliveryJobLiveActivityNotificationUnexpected", - {}, + { + ...ApnsDeliveryJobContext, + kind: LiveActivityKindSchema, + }, ) { override get message(): string { - return "Live Activity jobs must not carry push notification payloads."; + return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} must not carry a push notification payload.`; } } export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass()( "ApnsDeliveryJobPushNotificationMissing", - {}, + ApnsDeliveryJobContext, ) { override get message(): string { - return "Push notification jobs require a notification payload."; + return `APNs push notification job ${this.jobId} requires a notification payload.`; } } export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass()( "ApnsDeliveryJobPushNotificationAggregateUnexpected", - {}, + ApnsDeliveryJobContext, ) { override get message(): string { - return "Push notification jobs must not carry aggregate state."; + return `APNs push notification job ${this.jobId} must not carry aggregate state.`; } } export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobCreatedAtInvalid", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + }, ) { override get message(): string { - return "Invalid APNs delivery job creation time."; + return `APNs delivery job ${this.jobId} has invalid creation time ${this.createdAt}.`; } } export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobExpiresAtInvalid", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + expiresAt: Schema.String, + }, ) { override get message(): string { - return "Invalid APNs delivery job expiry."; + return `APNs delivery job ${this.jobId} has invalid expiry ${this.expiresAt}.`; } } export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobTimeWindowInvalid", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + expiresAt: Schema.String, + }, ) { override get message(): string { - return "Invalid APNs delivery job time window."; + return `APNs delivery job ${this.jobId} has invalid time window ${this.createdAt} to ${this.expiresAt}.`; } } export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass()( "ApnsDeliveryJobTimeWindowTooLong", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + expiresAt: Schema.String, + }, ) { override get message(): string { - return "APNs delivery job time window is too long."; + return `APNs delivery job ${this.jobId} time window ${this.createdAt} to ${this.expiresAt} is too long.`; } } export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobSignatureInvalid", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + }, ) { override get message(): string { - return "Invalid APNs delivery job signature."; + return `Invalid signature for APNs delivery job ${this.jobId}.`; } } @@ -156,11 +201,13 @@ export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type; export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass()( "ApnsDeliveryJobExpired", { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, expiresAt: Schema.String, }, ) { override get message(): string { - return `APNs delivery job expired at ${this.expiresAt}`; + return `APNs delivery job ${this.jobId} expired at ${this.expiresAt}.`; } } @@ -208,23 +255,46 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI case "live_activity_start": case "live_activity_update": if (payload.aggregate === null) { - return new ApnsDeliveryJobLiveActivityAggregateMissing(); + return new ApnsDeliveryJobLiveActivityAggregateMissing({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } if (payload.notification !== null) { - return new ApnsDeliveryJobLiveActivityNotificationUnexpected(); + return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } return null; case "live_activity_end": if (payload.notification !== null) { - return new ApnsDeliveryJobLiveActivityNotificationUnexpected(); + return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } return null; case "push_notification": if (payload.notification === null) { - return new ApnsDeliveryJobPushNotificationMissing(); + return new ApnsDeliveryJobPushNotificationMissing({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } if (payload.aggregate !== null) { - return new ApnsDeliveryJobPushNotificationAggregateUnexpected(); + return new ApnsDeliveryJobPushNotificationAggregateUnexpected({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } return null; } @@ -264,35 +334,73 @@ export function verifySignedApnsDeliveryJob(input: { readonly job: SignedApnsDeliveryJob; readonly nowMs: number; }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { - const invalidPayload = validatePayloadShape(input.job.payload); + const payload = input.job.payload; + const invalidPayload = validatePayloadShape(payload); if (invalidPayload !== null) { return invalidPayload; } - const createdAt = DateTime.make(input.job.payload.createdAt); + const createdAt = DateTime.make(payload.createdAt); if (Option.isNone(createdAt)) { - return new ApnsDeliveryJobCreatedAtInvalid(); + return new ApnsDeliveryJobCreatedAtInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + }); } - const expiresAt = DateTime.make(input.job.payload.expiresAt); + const expiresAt = DateTime.make(payload.expiresAt); if (Option.isNone(expiresAt)) { - return new ApnsDeliveryJobExpiresAtInvalid(); + return new ApnsDeliveryJobExpiresAtInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + expiresAt: payload.expiresAt, + }); } const createdAtMs = createdAt.value.epochMilliseconds; const expiresAtMs = expiresAt.value.epochMilliseconds; if (expiresAtMs <= createdAtMs) { - return new ApnsDeliveryJobTimeWindowInvalid(); + return new ApnsDeliveryJobTimeWindowInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + expiresAt: payload.expiresAt, + }); } if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { - return new ApnsDeliveryJobTimeWindowTooLong(); + return new ApnsDeliveryJobTimeWindowTooLong({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + expiresAt: payload.expiresAt, + }); } if (expiresAtMs <= input.nowMs) { - return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); + return new ApnsDeliveryJobExpired({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + expiresAt: payload.expiresAt, + }); } const expected = signatureForPayload({ secret: input.secret, - payload: input.job.payload, + payload, }); if (!timingSafeEqualBase64Url(input.job.signature, expected)) { - return new ApnsDeliveryJobSignatureInvalid(); + return new ApnsDeliveryJobSignatureInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } - return input.job.payload; + return payload; } From e06c33c38dba362e6a9a0733542cb37d56c024a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:05:13 -0700 Subject: [PATCH 155/257] [codex] Structure relay Live Activity errors (#3314) Co-authored-by: codex --- .../src/agentActivity/LiveActivities.test.ts | 88 +++++++ .../relay/src/agentActivity/LiveActivities.ts | 227 ++++++++++++------ 2 files changed, 241 insertions(+), 74 deletions(-) diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 8c6455c8622..8f3182279bb 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -197,4 +197,92 @@ describe("LiveActivities", () => { ), ); }); + + it.effect("preserves correlation context and causes for persistence failures", () => { + const cause = new Error("database unavailable"); + const registration: RelayLiveActivityRegistrationRequest = { + deviceId: "device-1" as RelayLiveActivityRegistrationRequest["deviceId"], + activityPushToken: + "activity-push-token" as RelayLiveActivityRegistrationRequest["activityPushToken"], + }; + const fakeDb = { + update: () => ({ + set: () => ({ where: () => Effect.fail(cause) }), + }), + insert: () => ({ + values: () => ({ onConflictDoUpdate: () => Effect.fail(cause) }), + }), + select: () => ({ + from: () => ({ + leftJoin: () => ({ where: () => Effect.fail(cause) }), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const liveActivities = yield* LiveActivities.LiveActivities; + const registrationError = yield* Effect.flip( + liveActivities.register({ userId: "user-1", registration }), + ); + const targetListError = yield* Effect.flip(liveActivities.listTargets({ userId: "user-1" })); + const deliveryErrors = yield* Effect.all( + [ + liveActivities.markDelivery({ + userId: "user-1", + deviceId: "device-1", + kind: "live_activity_update", + aggregate: null, + deliveredAt: "2026-05-25T00:00:10.000Z", + }), + liveActivities.markStartQueued({ + userId: "user-1", + deviceId: "device-1", + queuedAt: "2026-05-25T00:00:10.000Z", + }), + liveActivities.clearStartQueued({ userId: "user-1", deviceId: "device-1" }), + liveActivities.invalidateDeliveryToken({ + userId: "user-1", + deviceId: "device-1", + kind: "push_notification", + invalidatedAt: "2026-05-25T00:00:10.000Z", + }), + ].map(Effect.flip), + { concurrency: 1 }, + ); + + expect(registrationError).toMatchObject({ + userId: "user-1", + deviceId: "device-1", + cause, + message: + "Failed to persist Live Activity registration for user user-1 and device device-1.", + }); + expect(targetListError).toMatchObject({ + userId: "user-1", + cause, + message: "Failed to list Live Activity delivery targets for user user-1.", + }); + + const expectedDeliveryContext = [ + ["mark-delivery", "live_activity_update"], + ["mark-start-queued", null], + ["clear-start-queued", null], + ["invalidate-delivery-token", "push_notification"], + ] as const; + for (const [index, [operation, kind]] of expectedDeliveryContext.entries()) { + expect(deliveryErrors[index]).toMatchObject({ + operation, + userId: "user-1", + deviceId: "device-1", + kind, + cause, + message: `Failed to persist Live Activity state during ${operation} for user user-1 and device device-1.`, + }); + } + }).pipe( + Effect.provide( + LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); }); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 4417f4eba36..608ee0704ab 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -3,7 +3,10 @@ import type { RelayDeliveryKind, RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; -import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema } from "@t3tools/contracts/relay"; +import { + RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema, + RelayDeliveryKind as RelayDeliveryKindSchema, +} from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -17,28 +20,46 @@ import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.t export class LiveActivityRegistrationPersistenceError extends Schema.TaggedErrorClass()( "LiveActivityRegistrationPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + deviceId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist Live Activity registration"; + return `Failed to persist Live Activity registration for user ${this.userId} and device ${this.deviceId}.`; } } export class LiveActivityTargetListPersistenceError extends Schema.TaggedErrorClass()( "LiveActivityTargetListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list Live Activity delivery targets"; + return `Failed to list Live Activity delivery targets for user ${this.userId}.`; } } export class LiveActivityDeliveryMarkPersistenceError extends Schema.TaggedErrorClass()( "LiveActivityDeliveryMarkPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals([ + "mark-delivery", + "mark-start-queued", + "clear-start-queued", + "invalidate-delivery-token", + ]), + userId: Schema.String, + deviceId: Schema.String, + kind: Schema.NullOr(RelayDeliveryKindSchema), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist Live Activity delivery state"; + return `Failed to persist Live Activity state during ${this.operation} for user ${this.userId} and device ${this.deviceId}.`; } } @@ -110,11 +131,11 @@ export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return LiveActivities.of({ - register: Effect.fn("relay.live_activities.register")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.mobile.device_id": input.registration.deviceId, - }); + register: Effect.fn("relay.live_activities.register")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.registration.deviceId, + }); + yield* Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); const registration = input.registration; @@ -155,9 +176,17 @@ export const make = Effect.gen(function* () { updatedAt, }, }); - }, - Effect.mapError((cause) => new LiveActivityRegistrationPersistenceError({ cause })), - ), + }).pipe( + Effect.mapError( + (cause) => + new LiveActivityRegistrationPersistenceError({ + userId: input.userId, + deviceId: input.registration.deviceId, + cause, + }), + ), + ); + }), listTargets: Effect.fn("relay.live_activities.list_targets")(function* (input) { return yield* db @@ -207,16 +236,22 @@ export const make = Effect.gen(function* () { ), ), Effect.map((rows): ReadonlyArray => rows), - Effect.mapError((cause) => new LiveActivityTargetListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new LiveActivityTargetListPersistenceError({ + userId: input.userId, + cause, + }), + ), ); }), - markDelivery: Effect.fn("relay.live_activities.mark_delivery")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.mobile.device_id": input.deviceId, - "relay.delivery.kind": input.kind, - }); + markDelivery: Effect.fn("relay.live_activities.mark_delivery")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + "relay.delivery.kind": input.kind, + }); + yield* Effect.gen(function* () { const aggregateJson = input.aggregate === null ? null @@ -257,9 +292,19 @@ export const make = Effect.gen(function* () { updatedAt: input.deliveredAt, }, }); - }, - Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause })), - ), + }).pipe( + Effect.mapError( + (cause) => + new LiveActivityDeliveryMarkPersistenceError({ + operation: "mark-delivery", + userId: input.userId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); + }), markStartQueued: Effect.fn("relay.live_activities.mark_start_queued")(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -287,7 +332,18 @@ export const make = Effect.gen(function* () { updatedAt: input.queuedAt, }, }) - .pipe(Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new LiveActivityDeliveryMarkPersistenceError({ + operation: "mark-start-queued", + userId: input.userId, + deviceId: input.deviceId, + kind: null, + cause, + }), + ), + ); }), clearStartQueued: Effect.fn("relay.live_activities.clear_start_queued")(function* (input) { @@ -303,7 +359,18 @@ export const make = Effect.gen(function* () { eq(relayLiveActivities.deviceId, input.deviceId), ), ) - .pipe(Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new LiveActivityDeliveryMarkPersistenceError({ + operation: "clear-start-queued", + userId: input.userId, + deviceId: input.deviceId, + kind: null, + cause, + }), + ), + ); }), invalidateDeliveryToken: Effect.fn("relay.live_activities.invalidate_delivery_token")( @@ -312,39 +379,58 @@ export const make = Effect.gen(function* () { "relay.mobile.device_id": input.deviceId, "relay.delivery.kind": input.kind, }); - if (input.kind === "push_notification") { - yield* db - .update(relayMobileDevices) - .set({ - pushToken: null, - updatedAt: input.invalidatedAt, - }) - .where( - and( - eq(relayMobileDevices.userId, input.userId), - eq(relayMobileDevices.deviceId, input.deviceId), - ), - ); - return; - } + yield* Effect.gen(function* () { + if (input.kind === "push_notification") { + yield* db + .update(relayMobileDevices) + .set({ + pushToken: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ); + return; + } + + if (input.kind === "live_activity_start") { + yield* db + .update(relayMobileDevices) + .set({ + pushToStartToken: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ); + yield* db + .update(relayLiveActivities) + .set({ + remoteStartQueuedAt: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), + ), + ); + return; + } - if (input.kind === "live_activity_start") { - yield* db - .update(relayMobileDevices) - .set({ - pushToStartToken: null, - updatedAt: input.invalidatedAt, - }) - .where( - and( - eq(relayMobileDevices.userId, input.userId), - eq(relayMobileDevices.deviceId, input.deviceId), - ), - ); yield* db .update(relayLiveActivities) .set({ + activityPushToken: null, remoteStartQueuedAt: null, + remoteStartedAt: null, + endedAt: input.invalidatedAt, updatedAt: input.invalidatedAt, }) .where( @@ -353,26 +439,19 @@ export const make = Effect.gen(function* () { eq(relayLiveActivities.deviceId, input.deviceId), ), ); - return; - } - - yield* db - .update(relayLiveActivities) - .set({ - activityPushToken: null, - remoteStartQueuedAt: null, - remoteStartedAt: null, - endedAt: input.invalidatedAt, - updatedAt: input.invalidatedAt, - }) - .where( - and( - eq(relayLiveActivities.userId, input.userId), - eq(relayLiveActivities.deviceId, input.deviceId), - ), - ); + }).pipe( + Effect.mapError( + (cause) => + new LiveActivityDeliveryMarkPersistenceError({ + operation: "invalidate-delivery-token", + userId: input.userId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); }, - Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause })), ), }); }); From 6888276f19d47b9a73546e92721a56abbc8ab6c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:05:35 -0700 Subject: [PATCH 156/257] [codex] Sanitize YAML schema diagnostics (#3427) Co-authored-by: codex --- packages/shared/src/schemaYaml.test.ts | 40 ++++++++++++++++++++++++-- packages/shared/src/schemaYaml.ts | 17 +++++++---- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/schemaYaml.test.ts b/packages/shared/src/schemaYaml.test.ts index 7b5a23acbe1..c4eaab88b02 100644 --- a/packages/shared/src/schemaYaml.test.ts +++ b/packages/shared/src/schemaYaml.test.ts @@ -50,9 +50,45 @@ tags: expect(decodeYaml("answer: 42\n")).toEqual({ answer: 42 }); }); - it("rejects malformed YAML", () => { + it("reports malformed YAML with safe structural diagnostics", () => { const decodeYaml = Schema.decodeUnknownSync(fromYaml(Schema.Unknown)); + const secret = "credential=secret-value"; + let error: unknown; - expect(() => decodeYaml("name: ok\n bad-indent: nope\n")).toThrow(); + try { + decodeYaml(`name: ${secret}\n bad-indent: nope\n`); + } catch (cause) { + error = cause; + } + + expect(Schema.isSchemaError(error)).toBe(true); + if (!Schema.isSchemaError(error)) { + throw new Error("Expected a schema error"); + } + expect(error.message).toBe("Invalid YAML (code=BLOCK_AS_IMPLICIT_KEY, line=1, column=7)."); + expect(error.message).not.toContain(secret); + }); + + it("does not expose stringify failure details", () => { + const encodeYaml = Schema.encodeSync(fromYaml(Schema.Unknown)); + const secret = "credential=secret-value"; + let error: unknown; + + try { + encodeYaml({ + toJSON() { + throw new Error(secret); + }, + }); + } catch (cause) { + error = cause; + } + + expect(Schema.isSchemaError(error)).toBe(true); + if (!Schema.isSchemaError(error)) { + throw new Error("Expected a schema error"); + } + expect(error.message).toBe("Failed to stringify YAML."); + expect(error.message).not.toContain(secret); }); }); diff --git a/packages/shared/src/schemaYaml.ts b/packages/shared/src/schemaYaml.ts index 1b0d10fb888..ddb1b5c916c 100644 --- a/packages/shared/src/schemaYaml.ts +++ b/packages/shared/src/schemaYaml.ts @@ -5,6 +5,7 @@ import * as SchemaGetter from "effect/SchemaGetter"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { + YAMLParseError, parse as parseYamlString, stringify as stringifyYamlValue, type CreateNodeOptions, @@ -22,8 +23,14 @@ export type YamlStringifyOptions = DocumentOptions & CreateNodeOptions & ToStringOptions; -function formatYamlError(error: unknown): string { - return error instanceof Error ? error.message : String(error); +function formatYamlParseError(error: unknown): string { + if (!(error instanceof YAMLParseError)) { + return "Invalid YAML."; + } + + const position = error.linePos?.[0]; + const location = position === undefined ? "" : `, line=${position.line}, column=${position.col}`; + return `Invalid YAML (code=${error.code}${location}).`; } /** @@ -56,7 +63,7 @@ export function parseYaml( Effect.try({ try: () => parseYamlString(input, options) as unknown, catch: (error) => - new SchemaIssue.InvalidValue(Option.some(input), { message: formatYamlError(error) }), + new SchemaIssue.InvalidValue(Option.none(), { message: formatYamlParseError(error) }), }), ); } @@ -90,8 +97,8 @@ export function stringifyYaml( return SchemaGetter.transformOrFail((input: unknown) => Effect.try({ try: () => stringifyYamlValue(input, options), - catch: (error) => - new SchemaIssue.InvalidValue(Option.some(input), { message: formatYamlError(error) }), + catch: () => + new SchemaIssue.InvalidValue(Option.none(), { message: "Failed to stringify YAML." }), }), ); } From 3e5f9f2cdd73c3afb1ab73634ca91c42098fbaba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:06:31 -0700 Subject: [PATCH 157/257] [codex] Structure browser recording failures (#3330) Co-authored-by: codex --- apps/web/src/browser/browserRecording.ts | 216 ++++++++++++++++++++--- 1 file changed, 193 insertions(+), 23 deletions(-) diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 2a8accd8625..5bb3364807d 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -3,12 +3,71 @@ import type { DesktopPreviewRecordingFrame, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +export class BrowserRecordingUnavailableError extends Schema.TaggedErrorClass()( + "BrowserRecordingUnavailableError", + { + tabId: Schema.String, + }, +) { + override get message(): string { + return `Browser recording is unavailable for tab ${this.tabId}.`; + } +} + +export class BrowserRecordingConflictError extends Schema.TaggedErrorClass()( + "BrowserRecordingConflictError", + { + requestedTabId: Schema.String, + activeTabId: Schema.String, + }, +) { + override get message(): string { + return `Cannot record tab ${this.requestedTabId} while tab ${this.activeTabId} is already being recorded.`; + } +} + +export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorClass()( + "BrowserRecordingCanvasUnavailableError", + { + tabId: Schema.String, + width: Schema.Number, + height: Schema.Number, + }, +) { + override get message(): string { + return `Browser recording canvas ${this.width}x${this.height} is unavailable for tab ${this.tabId}.`; + } +} + +export class BrowserRecordingOperationError extends Schema.TaggedErrorClass()( + "BrowserRecordingOperationError", + { + operation: Schema.Literals([ + "initialize-media-recorder", + "subscribe-frames", + "start-media-recorder", + "start-screencast", + "stop-screencast", + "stop-media-recorder", + "save-artifact", + "cleanup", + ]), + tabId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Browser recording operation ${this.operation} failed for tab ${this.tabId}.`; + } +} + interface ActiveRecording { readonly tabId: string; readonly canvas: HTMLCanvasElement; @@ -70,22 +129,41 @@ const clearActiveRecording = (recording: ActiveRecording): void => { export async function startBrowserRecording(tabId: string): Promise { const bridge = previewBridge; - if (!bridge) throw new Error("Browser recording is unavailable."); + if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); if (active) { if (active.tabId === tabId) return active.startedAt; - throw new Error("Another preview tab is already being recorded."); + throw new BrowserRecordingConflictError({ + requestedTabId: tabId, + activeTabId: active.tabId, + }); } const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, rect?.width ?? 1280); canvas.height = Math.max(1, rect?.height ?? 800); const context = canvas.getContext("2d", { alpha: false }); - if (!context) throw new Error("Browser recording canvas is unavailable."); - const mimeType = preferredMimeType(); - const recorder = new MediaRecorder(canvas.captureStream(12), { - mimeType, - videoBitsPerSecond: 4_000_000, - }); + if (!context) { + throw new BrowserRecordingCanvasUnavailableError({ + tabId, + width: canvas.width, + height: canvas.height, + }); + } + let mimeType: string; + let recorder: MediaRecorder; + try { + mimeType = preferredMimeType(); + recorder = new MediaRecorder(canvas.captureStream(12), { + mimeType, + videoBitsPerSecond: 4_000_000, + }); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "initialize-media-recorder", + tabId, + cause, + }); + } const startedAt = new Date().toISOString(); const chunks: Blob[] = []; recorder.addEventListener("dataavailable", (event) => { @@ -93,17 +171,52 @@ export async function startBrowserRecording(tabId: string): Promise { }); 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); - appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); - return startedAt; - } catch (error) { - await stopMediaRecorder(recorder); + unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); + } catch (cause) { clearActiveRecording(recording); - throw error; + throw new BrowserRecordingOperationError({ + operation: "subscribe-frames", + tabId, + cause, + }); + } + try { + recorder.start(1_000); + } catch (cause) { + clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ + operation: "start-media-recorder", + tabId, + cause, + }); + } + try { + await bridge.recording.startScreencast(tabId); + } catch (cause) { + let cleanupCause: unknown; + try { + await stopMediaRecorder(recorder); + } catch (error) { + cleanupCause = error; + } finally { + clearActiveRecording(recording); + } + throw new BrowserRecordingOperationError({ + operation: "start-screencast", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser recording start and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); } + appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); + return startedAt; } export async function stopBrowserRecording( @@ -112,17 +225,74 @@ export async function stopBrowserRecording( const bridge = previewBridge; const recording = active; if (!bridge || !recording || recording.tabId !== tabId) return null; + let result: + | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact } + | { readonly _tag: "Failure"; readonly error: unknown }; + try { + try { + await bridge.recording.stopScreencast(tabId); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "stop-screencast", + tabId, + cause, + }); + } + try { + await stopMediaRecorder(recording.recorder); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "stop-media-recorder", + tabId, + cause, + }); + } + try { + const blob = new Blob(recording.chunks, { type: recording.mimeType }); + const artifact = await bridge.recording.save( + tabId, + recording.mimeType, + new Uint8Array(await blob.arrayBuffer()), + ); + result = { _tag: "Success", artifact }; + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "save-artifact", + tabId, + cause, + }); + } + } catch (error) { + result = { _tag: "Failure", error }; + } + + let cleanupError: BrowserRecordingOperationError | undefined; try { - await bridge.recording.stopScreencast(tabId); await stopMediaRecorder(recording.recorder); - const blob = new Blob(recording.chunks, { type: recording.mimeType }); - return await bridge.recording.save( + } catch (cause) { + cleanupError = new BrowserRecordingOperationError({ + operation: "stop-media-recorder", tabId, - recording.mimeType, - new Uint8Array(await blob.arrayBuffer()), - ); + cause, + }); } finally { - await stopMediaRecorder(recording.recorder); clearActiveRecording(recording); } + + if (result._tag === "Failure") { + if (cleanupError) { + throw new BrowserRecordingOperationError({ + operation: "cleanup", + tabId, + cause: new AggregateError( + [result.error, cleanupError], + `Browser recording stop and cleanup failed for tab ${tabId}.`, + { cause: result.error }, + ), + }); + } + throw result.error; + } + if (cleanupError) throw cleanupError; + return result.artifact; } From 8c03188c1cad4220baac08b1580823b4baeaae70 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:07:09 -0700 Subject: [PATCH 158/257] [codex] Structure preview automation failures (#3333) Co-authored-by: codex --- .../preview/PreviewAutomationOwner.tsx | 218 +++++++++++++++--- .../previewAutomationRequestConsumer.test.ts | 20 ++ .../previewAutomationRequestConsumer.ts | 34 ++- 3 files changed, 232 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index 0264cf7a01f..e3d08ea131b 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -2,14 +2,20 @@ import { useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { - PreviewAutomationNavigateInput, - PreviewAutomationOpenInput, - PreviewAutomationRequest, - PreviewAutomationOwner as PreviewAutomationOwnerState, - PreviewAutomationStatus, - ScopedThreadRef, +import { + EnvironmentId, + type PreviewAutomationNavigateInput, + type PreviewAutomationOpenInput, + PreviewAutomationOperation, + type PreviewAutomationOwner as PreviewAutomationOwnerState, + type PreviewAutomationRequest, + type PreviewAutomationStatus, + PreviewTabId, + type ScopedThreadRef, + ThreadId, + TrimmedNonEmptyString, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; import { @@ -30,6 +36,100 @@ import { createPreviewAutomationRequestConsumerAtom, } from "./previewAutomationRequestConsumer"; +export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationOverlayTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError"; + } + + override get message(): string { + return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationNavigationTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + readiness: Schema.Literals(["domContentLoaded", "load"]), + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError"; + } + + override get message(): string { + return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( + "PreviewAutomationStaleOwnerError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + expectedThreadId: ThreadId, + requestedThreadId: ThreadId, + }, +) { + get responseTag() { + return "PreviewAutomationUnavailableError"; + } + + override get message(): string { + return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; + } +} + +export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetUnavailableError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + bridgeAvailable: Schema.Boolean, + }, +) { + get responseTag() { + return "PreviewAutomationTabNotFoundError"; + } + + override get message(): string { + return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; + } +} + +export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( + "PreviewAutomationRecordingNotActiveError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + }, +) { + get responseTag() { + return "PreviewAutomationExecutionError"; + } + + override get message(): string { + return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; + } +} + export function observeAutomationOwnerConnectedGeneration( previousGeneration: number | null, connectedGeneration: number | null, @@ -51,6 +151,7 @@ export function observeAutomationOwnerConnectedGeneration( const waitForDesktopOverlay = async ( threadRef: ScopedThreadRef, + requestId: string, timeoutMs: number, ): Promise => { const deadline = Date.now() + timeoutMs; @@ -63,20 +164,26 @@ const waitForDesktopOverlay = async ( } await new Promise((resolve) => window.setTimeout(resolve, 50)); } - const error = new Error(`Preview webview did not register within ${timeoutMs}ms.`); - error.name = "PreviewAutomationTimeoutError"; - throw error; + throw new PreviewAutomationOverlayTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + timeoutMs, + }); }; const waitForNavigationReadiness = async ( + threadRef: ScopedThreadRef, + requestId: string, tabId: string, readiness: PreviewAutomationNavigateInput["readiness"], timeoutMs: number, ): Promise => { - if (!previewBridge || readiness === "none") return; + const targetReadiness = readiness ?? "load"; + if (!previewBridge || targetReadiness === "none") return; const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { - if (readiness === "domContentLoaded") { + if (targetReadiness === "domContentLoaded") { const readyState = await previewBridge.automation.evaluate(tabId, { expression: "document.readyState", }); @@ -87,9 +194,14 @@ const waitForNavigationReadiness = async ( } await new Promise((resolve) => window.setTimeout(resolve, 50)); } - const error = new Error(`Preview navigation did not become ready within ${timeoutMs}ms.`); - error.name = "PreviewAutomationTimeoutError"; - throw error; + throw new PreviewAutomationNavigationTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + readiness: targetReadiness, + timeoutMs, + }); }; const currentStatus = async ( @@ -113,12 +225,6 @@ const currentStatus = async ( }; }; -const previewTabNotFoundError = (): Error => { - const error = new Error("Preview tab is not initialized."); - error.name = "PreviewAutomationTabNotFoundError"; - return error; -}; - export function PreviewAutomationOwner(props: { readonly threadRef: ScopedThreadRef; readonly visible: boolean; @@ -182,12 +288,23 @@ export function PreviewAutomationOwner(props: { const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { if (request.threadId !== threadRef.threadId) { - const error = new Error("Preview automation request targeted a stale thread owner."); - error.name = "PreviewAutomationUnavailableError"; - throw error; + throw new PreviewAutomationStaleOwnerError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + expectedThreadId: threadRef.threadId, + requestedThreadId: request.threadId, + }); } const state = readThreadPreviewState(threadRef); const tabId = request.tabId ?? state.snapshot?.tabId ?? null; + const unavailableTarget = { + requestId: request.requestId, + operation: request.operation, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + bridgeAvailable: Boolean(previewBridge), + }; switch (request.operation) { case "status": return currentStatus(threadRef, visible); @@ -215,11 +332,13 @@ export function PreviewAutomationOwner(props: { if (input.show ?? true) { useRightPanelStore.getState().openBrowser(threadRef, activeTabId); } - await waitForDesktopOverlay(threadRef, request.timeoutMs); + await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); return currentStatus(threadRef, input.show ?? true); } case "navigate": { - if (!previewBridge || !tabId) throw previewTabNotFoundError(); + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } const input = request.input as PreviewAutomationNavigateInput; const resolution = resolveBrowserNavigationTarget( threadRef.environmentId, @@ -227,6 +346,8 @@ export function PreviewAutomationOwner(props: { ); await previewBridge.navigate(tabId, resolution.resolvedUrl); await waitForNavigationReadiness( + threadRef, + request.requestId, tabId, input.readiness ?? "load", input.timeoutMs ?? request.timeoutMs, @@ -234,46 +355,62 @@ export function PreviewAutomationOwner(props: { return currentStatus(threadRef, visible); } case "snapshot": - if (!previewBridge || !tabId) throw previewTabNotFoundError(); + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } return previewBridge.automation.snapshot(tabId); case "click": - if (!previewBridge || !tabId) throw previewTabNotFoundError(); + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } return previewBridge.automation.click( tabId, request.input as Parameters[1], ); case "type": - if (!previewBridge || !tabId) throw previewTabNotFoundError(); + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } return previewBridge.automation.type( tabId, request.input as Parameters[1], ); case "press": - if (!previewBridge || !tabId) throw previewTabNotFoundError(); + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } return previewBridge.automation.press( tabId, request.input as Parameters[1], ); case "scroll": - if (!previewBridge || !tabId) throw previewTabNotFoundError(); + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } return previewBridge.automation.scroll( tabId, request.input as Parameters[1], ); case "evaluate": - if (!previewBridge || !tabId) throw previewTabNotFoundError(); + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } return previewBridge.automation.evaluate( tabId, request.input as Parameters[1], ); case "waitFor": - if (!previewBridge || !tabId) throw previewTabNotFoundError(); + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } return previewBridge.automation.waitFor( tabId, request.input as Parameters[1], ); case "recordingStart": { - if (!tabId) throw previewTabNotFoundError(); + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } const startedAt = await startBrowserRecording(tabId); return { tabId, @@ -282,9 +419,18 @@ export function PreviewAutomationOwner(props: { }; } case "recordingStop": { - if (!tabId) throw previewTabNotFoundError(); + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } const artifact = await stopBrowserRecording(tabId); - if (!artifact) throw new Error("No active recording exists for this preview tab."); + if (!artifact) { + throw new PreviewAutomationRecordingNotActiveError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + }); + } return artifact; } } diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts index 501fb156d63..5cc89c00e9c 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -78,4 +78,24 @@ describe("previewAutomationRequestConsumer", () => { message: "No preview tab", }); }); + + it("serializes structured automation context without leaking causes", () => { + const error = Object.assign(new Error("Preview target unavailable"), { + name: "PreviewAutomationTargetUnavailableError", + _tag: "PreviewAutomationTargetUnavailableError", + responseTag: "PreviewAutomationTabNotFoundError", + requestId: "request-1", + threadId: "thread-1", + cause: new Error("private bridge failure"), + }); + + expect(serializePreviewAutomationError(error)).toEqual({ + _tag: "PreviewAutomationTabNotFoundError", + message: "Preview target unavailable", + detail: { + requestId: "request-1", + threadId: "thread-1", + }, + }); + }); }); diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts index bb8d8d58d89..5cf5590335f 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts @@ -21,14 +21,40 @@ export function serializePreviewAutomationError( error: unknown, ): NonNullable { if (error instanceof Error) { - const detail = + const explicitDetail = "detail" in error && (error as { detail?: unknown }).detail !== undefined ? (error as { detail?: unknown }).detail : undefined; + const structuralDetail = + "_tag" in error && + typeof (error as { _tag?: unknown })._tag === "string" && + (error as { _tag: string })._tag.startsWith("PreviewAutomation") + ? Object.fromEntries( + Object.entries(error).filter( + ([key]) => + key !== "_tag" && + key !== "cause" && + key !== "name" && + key !== "message" && + key !== "stack" && + key !== "detail" && + key !== "responseTag", + ), + ) + : undefined; + const detail = explicitDetail ?? structuralDetail; + const responseTag = + "responseTag" in error && + typeof (error as { responseTag?: unknown }).responseTag === "string" && + (error as { responseTag: string }).responseTag.startsWith("PreviewAutomation") + ? (error as { responseTag: string }).responseTag + : undefined; return { - _tag: error.name.startsWith("PreviewAutomation") - ? error.name - : "PreviewAutomationExecutionError", + _tag: + responseTag ?? + (error.name.startsWith("PreviewAutomation") + ? error.name + : "PreviewAutomationExecutionError"), message: error.message, ...(detail === undefined ? {} : { detail }), }; From defd9dfe20f8c333b8eab2769e3205dfb560395c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:07:57 -0700 Subject: [PATCH 159/257] [codex] Structure Electron menu failures (#3317) Co-authored-by: codex --- .../desktop/src/electron/ElectronMenu.test.ts | 98 +++++++++++++++- apps/desktop/src/electron/ElectronMenu.ts | 109 ++++++++++++++---- 2 files changed, 179 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 4dd8066e3c6..3dc218d8252 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -1,5 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; @@ -24,6 +27,10 @@ vi.mock("electron", () => ({ import * as ElectronMenu from "./ElectronMenu.ts"; +const TestLayer = ElectronMenu.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); @@ -42,7 +49,7 @@ describe("ElectronMenu", () => { assert.isTrue(Option.isNone(selectedItemId)); assert.equal(buildFromTemplateMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("resolves with the clicked leaf item id", () => @@ -69,7 +76,7 @@ describe("ElectronMenu", () => { }); assert.equal(Option.getOrNull(selectedItemId), "copy"); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("resolves with none when the menu closes without a click", () => @@ -93,7 +100,7 @@ describe("ElectronMenu", () => { enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("defers popupTemplate side effects until the returned Effect runs", () => @@ -114,6 +121,89 @@ describe("ElectronMenu", () => { assert.equal(buildFromTemplateMock.mock.calls.length, 1); assert.equal(popupMock.mock.calls.length, 1); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves application-menu failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("application menu build failed"); + buildFromTemplateMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.setApplicationMenu([{ label: "File" }, { label: "Edit" }]), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "set-application-menu"); + assert.equal(error.platform, "linux"); + assert.isNull(error.windowId); + assert.equal(error.itemCount, 2); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves popup-template failures with window context", () => + Effect.gen(function* () { + const cause = new Error("popup failed"); + buildFromTemplateMock.mockReturnValueOnce({ + popup: () => { + throw cause; + }, + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.popupTemplate({ + window: { id: 41 } as Electron.BrowserWindow, + template: [{ label: "Copy" }], + }), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "popup-template"); + assert.equal(error.windowId, 41); + assert.equal(error.itemCount, 1); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves context-menu failures with normalized item context", () => + Effect.gen(function* () { + const cause = new Error("context menu build failed"); + buildFromTemplateMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.showContextMenu({ + window: { id: 42 } as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.none(), + }), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "show-context-menu"); + assert.equal(error.windowId, 42); + assert.equal(error.itemCount, 1); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index d9eb3b22eff..09fb5d1807d 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.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 Electron from "electron"; @@ -23,6 +24,28 @@ export interface ElectronMenuTemplateInput { readonly template: readonly Electron.MenuItemConstructorOptions[]; } +const ElectronMenuOperation = Schema.Literals([ + "set-application-menu", + "popup-template", + "show-context-menu", +]); + +export class ElectronMenuOperationError extends Schema.TaggedErrorClass()( + "ElectronMenuOperationError", + { + operation: ElectronMenuOperation, + platform: Schema.String, + windowId: Schema.NullOr(Schema.Number), + itemCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const window = this.windowId === null ? "" : ` for window ${this.windowId}`; + return `Electron menu operation ${JSON.stringify(this.operation)} failed${window} with ${this.itemCount} items on ${this.platform}.`; + } +} + export class ElectronMenu extends Context.Service< ElectronMenu, { @@ -142,16 +165,36 @@ export const make = Effect.gen(function* () { return ElectronMenu.of({ setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), + Effect.try({ + try: () => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }, + catch: (cause) => + new ElectronMenuOperationError({ + operation: "set-application-menu", + platform, + windowId: null, + itemCount: template.length, + cause, + }), + }).pipe(Effect.orDie), popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; - } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), + input.template.length === 0 + ? Effect.void + : Effect.try({ + try: () => + Electron.Menu.buildFromTemplate([...input.template]).popup({ + window: input.window, + }), + catch: (cause) => + new ElectronMenuOperationError({ + operation: "popup-template", + platform, + windowId: input.window.id, + itemCount: input.template.length, + cause, + }), + }).pipe(Effect.orDie), showContextMenu: (input) => Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); @@ -169,21 +212,39 @@ export const make = Effect.gen(function* () { resume(Effect.succeed(selectedItemId)); }; - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); - const popupOptions = Option.match(popupPosition, { - onNone: (): Electron.PopupOptions => ({ - window: input.window, - callback: () => complete(Option.none()), - }), - onSome: (position): Electron.PopupOptions => ({ - window: input.window, - x: position.x, - y: position.y, - callback: () => complete(Option.none()), - }), - }); - menu.popup(popupOptions); + try { + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + } catch (cause) { + if (completed) { + return; + } + completed = true; + resume( + Effect.die( + new ElectronMenuOperationError({ + operation: "show-context-menu", + platform, + windowId: input.window.id, + itemCount: normalizedItems.length, + cause, + }), + ), + ); + } }), }); }); From e85144a58ce53d30d4d8595788896b37f5b966b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:08:37 -0700 Subject: [PATCH 160/257] [codex] Structure relay delivery attempt errors (#3312) Co-authored-by: codex --- .../agentActivity/DeliveryAttempts.test.ts | 91 ++++++++++++ .../src/agentActivity/DeliveryAttempts.ts | 133 ++++++++++++------ 2 files changed, 181 insertions(+), 43 deletions(-) diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts index 8231fe17afa..2f3d948983d 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts @@ -320,4 +320,95 @@ describe("DeliveryAttempts", () => { ), ); }); + + it.effect("preserves operation context and causes for persistence failures", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + insert: () => ({ + values: (values: Record) => + values.kind === "record" + ? Effect.fail(cause) + : { + onConflictDoNothing: () => ({ + returning: () => Effect.fail(cause), + }), + }, + }), + update: () => ({ + set: () => ({ + where: () => Effect.fail(cause), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + const recordError = yield* Effect.flip( + attempts.record({ + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "record", + sourceJobId: "job-1", + token: "apns-token", + }), + ); + const claimError = yield* Effect.flip( + attempts.claimSourceJob({ + userId: "user-2", + environmentId: "env-2", + threadId: "thread-2", + deviceId: "device-2", + kind: "claim", + sourceJobId: "job-2", + token: "apns-token", + }), + ); + const completionError = yield* Effect.flip( + attempts.completeSourceJob({ sourceJobId: "job-3", apnsStatus: 500 }), + ); + + expect(recordError).toMatchObject({ + operation: "record", + sourceJobId: "job-1", + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "record", + cause, + message: "Failed to persist APNs delivery attempt during record.", + }); + expect(claimError).toMatchObject({ + operation: "claim-source-job", + sourceJobId: "job-2", + userId: "user-2", + environmentId: "env-2", + threadId: "thread-2", + deviceId: "device-2", + kind: "claim", + cause, + message: "Failed to persist APNs delivery attempt during claim-source-job.", + }); + expect(completionError).toMatchObject({ + operation: "complete-source-job", + sourceJobId: "job-3", + userId: null, + environmentId: null, + threadId: null, + deviceId: null, + kind: null, + cause, + message: "Failed to persist APNs delivery attempt during complete-source-job.", + }); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); }); diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index 8845588329a..843415abfe8 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -12,10 +12,19 @@ import { relayDeliveryAttempts } from "../persistence/schema.ts"; export class DeliveryAttemptRecordPersistenceError extends Schema.TaggedErrorClass()( "DeliveryAttemptRecordPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["record", "claim-source-job", "complete-source-job"]), + sourceJobId: Schema.NullOr(Schema.String), + userId: Schema.NullOr(Schema.String), + environmentId: Schema.NullOr(Schema.String), + threadId: Schema.NullOr(Schema.String), + deviceId: Schema.NullOr(Schema.String), + kind: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist APNs delivery attempt"; + return `Failed to persist APNs delivery attempt during ${this.operation}.`; } } @@ -99,30 +108,43 @@ export const make = Effect.gen(function* () { }; return DeliveryAttempts.of({ - record: Effect.fn("relay.delivery_attempts.record")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.delivery.kind": input.kind, - ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), - ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), - ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), - ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), - }); + record: Effect.fn("relay.delivery_attempts.record")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.delivery.kind": input.kind, + ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), + ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), + }); + yield* Effect.gen(function* () { const id = yield* crypto.randomUUIDv4; const createdAt = DateTime.formatIso(yield* DateTime.now); yield* db.insert(relayDeliveryAttempts).values(insertValues(input, id, createdAt)); - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), - claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.delivery.kind": input.kind, - "relay.delivery.job_id": input.sourceJobId, - ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), - ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), - ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), - }); + }).pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "record", + sourceJobId: input.sourceJobId ?? null, + userId: input.userId, + environmentId: input.environmentId, + threadId: input.threadId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); + }), + claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.delivery.kind": input.kind, + "relay.delivery.job_id": input.sourceJobId, + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), + ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), + }); + return yield* Effect.gen(function* () { const id = yield* crypto.randomUUIDv4; const now = yield* DateTime.now; const createdAt = DateTime.formatIso(now); @@ -179,26 +201,51 @@ export const make = Effect.gen(function* () { ) .returning({ id: relayDeliveryAttempts.id }); return reclaimed.length > 0 ? "claimed" : "in_flight"; - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), - completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId }); - const completedAt = DateTime.formatIso(yield* DateTime.now); - yield* db - .update(relayDeliveryAttempts) - .set({ - createdAt: completedAt, - apnsStatus: input.apnsStatus ?? null, - apnsReason: input.apnsReason ?? null, - apnsId: input.apnsId ?? null, - transportError: input.transportError ?? null, - }) - .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)); - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), + }).pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "claim-source-job", + sourceJobId: input.sourceJobId, + userId: input.userId, + environmentId: input.environmentId, + threadId: input.threadId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); + }), + completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId }); + const completedAt = DateTime.formatIso(yield* DateTime.now); + yield* db + .update(relayDeliveryAttempts) + .set({ + createdAt: completedAt, + apnsStatus: input.apnsStatus ?? null, + apnsReason: input.apnsReason ?? null, + apnsId: input.apnsId ?? null, + transportError: input.transportError ?? null, + }) + .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)) + .pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "complete-source-job", + sourceJobId: input.sourceJobId, + userId: null, + environmentId: null, + threadId: null, + deviceId: null, + kind: null, + cause, + }), + ), + ); + }), }); }); From f1052dd8b1040b22ad5a2e21282ee9980f98dbc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:09:34 -0700 Subject: [PATCH 161/257] [codex] Structure Discord release webhook failures (#3285) Co-authored-by: codex --- scripts/notify-discord-release.test.ts | 160 +++++++++++++++++++------ scripts/notify-discord-release.ts | 82 ++++++++++--- 2 files changed, 191 insertions(+), 51 deletions(-) diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index 23f73fa0d76..7ca108f3188 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -1,6 +1,25 @@ import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; -import { buildDiscordReleaseAnnouncement } from "./notify-discord-release.ts"; +import { + buildDiscordReleaseAnnouncement, + isDiscordReleaseAnnouncementError, + postDiscordWebhook, +} from "./notify-discord-release.ts"; + +const latestAnnouncement = { + target: "latest", + roleId: "222222222222222222", + releaseName: "T3 Code v1.2.3", + version: "1.2.3", + tag: "v1.2.3", + releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), + timestamp: "2026-05-01T01:41:00.000Z", +} as const; + +const webhookUrl = new URL("https://discord.com/api/webhooks/123456/secret-token"); it("builds a prerelease Discord announcement for nightly subscribers", () => { assert.deepStrictEqual( @@ -47,42 +66,109 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => { }); it("builds a latest Discord announcement for stable subscribers", () => { - assert.deepStrictEqual( - buildDiscordReleaseAnnouncement({ - target: "latest", - roleId: "222222222222222222", - releaseName: "T3 Code v1.2.3", - version: "1.2.3", - tag: "v1.2.3", - releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), - timestamp: "2026-05-01T01:41:00.000Z", - }), - { - content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", - allowed_mentions: { - roles: ["222222222222222222"], - }, - embeds: [ - { - title: "T3 Code v1.2.3", - url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", - description: "A new T3 Code latest release is available.", - color: 0x2ecc71, - fields: [ - { - name: "Version", - value: "1.2.3", - inline: true, - }, - { - name: "Tag", - value: "v1.2.3", - inline: true, - }, - ], - timestamp: "2026-05-01T01:41:00.000Z", - }, - ], + assert.deepStrictEqual(buildDiscordReleaseAnnouncement(latestAnnouncement), { + content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", + allowed_mentions: { + roles: ["222222222222222222"], }, + embeds: [ + { + title: "T3 Code v1.2.3", + url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", + description: "A new T3 Code latest release is available.", + color: 0x2ecc71, + fields: [ + { + name: "Version", + value: "1.2.3", + inline: true, + }, + { + name: "Tag", + value: "v1.2.3", + inline: true, + }, + ], + timestamp: "2026-05-01T01:41:00.000Z", + }, + ], + }); +}); + +it.effect("preserves webhook request context and the full client cause chain", () => { + const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); + const requestCause = new Error("request encoder unavailable"); + let clientError: HttpClientError.HttpClientError | undefined; + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + clientError = new HttpClientError.HttpClientError({ + reason: new HttpClientError.EncodeError({ + request, + cause: requestCause, + }), + }); + return Effect.fail(clientError); + }), ); + + return Effect.gen(function* () { + const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( + Effect.provide(httpClientLayer), + Effect.flip, + ); + + if (error._tag !== "DiscordReleaseWebhookRequestError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.target, "latest"); + assert.equal(error.releaseName, latestAnnouncement.releaseName); + assert.equal(error.version, latestAnnouncement.version); + assert.equal(error.tag, latestAnnouncement.tag); + assert.equal(error.releaseUrl, latestAnnouncement.releaseUrl.href); + assert.equal(error.webhookOrigin, webhookUrl.origin); + assert.equal(error.webhookPathnameSegmentCount, 4); + assert.equal(error.contentLength, payload.content.length); + assert.equal(error.embedCount, 1); + assert.equal(error.allowedRoleMentionCount, 1); + assert.equal(error.hasRoleMentionSyntax, true); + assert.equal(error.cause, clientError); + assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause); + assert.ok(!error.message.includes(requestCause.message)); + assert.equal(isDiscordReleaseAnnouncementError(error), true); + }); +}); + +it.effect("preserves a non-success response error with structured status context", () => { + const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("invalid webhook", { status: 400 })), + ), + ), + ); + + return Effect.gen(function* () { + const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( + Effect.provide(httpClientLayer), + Effect.flip, + ); + + if (error._tag !== "DiscordReleaseWebhookResponseError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.target, "latest"); + assert.equal(error.tag, latestAnnouncement.tag); + assert.equal(error.webhookOrigin, webhookUrl.origin); + assert.equal(error.webhookPathnameSegmentCount, 4); + assert.equal(error.status, 400); + if (!HttpClientError.isHttpClientError(error.cause)) { + assert.fail("Expected HttpClientError cause"); + } + assert.equal(error.cause.reason._tag, "StatusCodeError"); + assert.ok(!error.message.includes(error.cause.message)); + assert.equal(isDiscordReleaseAnnouncementError(error), true); + }); }); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 1e3106b05ed..d013e7c2f65 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -3,7 +3,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -19,7 +18,7 @@ import { export type DiscordReleaseTarget = "prerelease" | "latest"; -interface DiscordReleaseAnnouncementOptions { +export interface DiscordReleaseAnnouncementOptions { readonly target: DiscordReleaseTarget; readonly roleId: string; readonly releaseName: string; @@ -52,10 +51,51 @@ const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); -class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const discordReleaseErrorContext = { + target: Schema.Literals(["prerelease", "latest"]), + releaseName: Schema.String, + version: Schema.String, + tag: Schema.String, + releaseUrl: Schema.String, + webhookOrigin: Schema.String, + webhookPathnameSegmentCount: Schema.Number, + contentLength: Schema.Number, + embedCount: Schema.Number, + allowedRoleMentionCount: Schema.Number, + hasRoleMentionSyntax: Schema.Boolean, +}; + +export class DiscordReleaseWebhookRequestError extends Schema.TaggedErrorClass()( + "DiscordReleaseWebhookRequestError", + { + ...discordReleaseErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to post Discord ${this.target} release announcement for "${this.tag}" to ${this.webhookOrigin}.`; + } +} + +export class DiscordReleaseWebhookResponseError extends Schema.TaggedErrorClass()( + "DiscordReleaseWebhookResponseError", + { + ...discordReleaseErrorContext, + status: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Discord ${this.target} release webhook for "${this.tag}" returned status ${this.status}.`; + } +} + +export const DiscordReleaseAnnouncementError = Schema.Union([ + DiscordReleaseWebhookRequestError, + DiscordReleaseWebhookResponseError, +]); +export type DiscordReleaseAnnouncementError = typeof DiscordReleaseAnnouncementError.Type; +export const isDiscordReleaseAnnouncementError = Schema.is(DiscordReleaseAnnouncementError); const targetLabels = { prerelease: "Prerelease", @@ -117,9 +157,10 @@ export const buildDiscordReleaseAnnouncement = ( ], }); -const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( +export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( webhookUrl: URL, payload: DiscordWebhookPayload, + announcement: DiscordReleaseAnnouncementOptions, ) { const httpClient = (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -135,13 +176,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( }), ); + const errorContext = { + target: announcement.target, + releaseName: announcement.releaseName, + version: announcement.version, + tag: announcement.tag, + releaseUrl: announcement.releaseUrl.href, + webhookOrigin: webhookUrl.origin, + webhookPathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length, + ...summarizePayload(payload), + } as const; + const response = yield* HttpClientRequest.post(webhookUrl).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => - new DiscordReleaseAnnouncementError({ - message: "Failed to post Discord release announcement.", + new DiscordReleaseWebhookRequestError({ + ...errorContext, cause, }), ), @@ -157,8 +209,9 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.mapError( (cause) => - new DiscordReleaseAnnouncementError({ - message: `Discord webhook returned status ${response.status}.`, + new DiscordReleaseWebhookResponseError({ + ...errorContext, + status: response.status, cause, }), ), @@ -208,7 +261,7 @@ export const notifyDiscordReleaseCommand = Command.make( const webhookUrl = yield* DiscordWebhookUrl; const timestamp = DateTime.formatIso(yield* DateTime.now); - const payload = buildDiscordReleaseAnnouncement({ + const announcement = { target, roleId, releaseName, @@ -216,12 +269,13 @@ export const notifyDiscordReleaseCommand = Command.make( tag, releaseUrl, timestamp, - }); + } satisfies DiscordReleaseAnnouncementOptions; + const payload = buildDiscordReleaseAnnouncement(announcement); yield* Effect.logInfo("discord release announcement payload built").pipe( Effect.annotateLogs(summarizePayload(payload)), ); - yield* postDiscordWebhook(webhookUrl, payload); + yield* postDiscordWebhook(webhookUrl, payload, announcement); yield* Effect.logInfo("discord release announcement completed"); }), ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); From ce11e6facd6e557c382b3995242e2b501317fecd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:10:51 -0700 Subject: [PATCH 162/257] [codex] Structure desktop browser session errors (#3273) Co-authored-by: codex --- .../src/preview/BrowserSession.test.ts | 108 +++++++++++++++++ apps/desktop/src/preview/BrowserSession.ts | 109 +++++++++++++++--- 2 files changed, 199 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 5526e5e0e54..e258bb2dfc5 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { beforeEach, vi } from "vite-plus/test"; const { fromPartition, sessions } = vi.hoisted(() => ({ @@ -59,6 +61,64 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("preserves partition scope and the platform failure chain", () => { + const nativeCause = new Error("native digest failed"); + const platformCause = PlatformError.systemError({ + _tag: "Unknown", + module: "Crypto", + method: "digest", + cause: nativeCause, + }); + const failingCryptoLayer = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: () => Effect.fail(platformCause), + }), + ); + + return Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); + + assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); + assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); + assert.isTrue(BrowserSession.isBrowserSessionError(error)); + assert.equal(error.scope, "environment-a"); + assert.strictEqual(error.cause, platformCause); + assert.strictEqual(error.cause.reason.cause, nativeCause); + assert.equal( + error.message, + "Failed to derive a desktop preview browser partition for scope environment-a.", + ); + assert.notInclude(error.message, nativeCause.message); + }).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(failingCryptoLayer)))); + }); + + it.effect("preserves session scope, partition, and the Electron failure", () => + Effect.gen(function* () { + const cause = new Error("Electron session failed"); + fromPartition.mockImplementationOnce(() => { + throw cause; + }); + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("environment-b"); + const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); + + assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); + assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); + assert.isTrue(BrowserSession.isBrowserSessionError(error)); + assert.equal(error.scope, "environment-b"); + assert.equal(error.partition, partition); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to create a desktop preview browser session for scope environment-b (partition ${partition}).`, + ); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears storage and cache for every created session", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -80,4 +140,52 @@ describe("BrowserSession", () => { } }).pipe(Effect.provide(layer)), ); + + it.effect("correlates clear failures while still attempting every session", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("scope-b"); + const firstPartition = yield* browserSessions.getPartition("scope-a"); + const secondPartition = yield* browserSessions.getPartition("scope-b"); + const firstSession = sessions.get(firstPartition); + const secondSession = sessions.get(secondPartition); + assert.isDefined(firstSession); + assert.isDefined(secondSession); + + const storageCause = new Error("storage clear failed"); + secondSession.clearStorageData.mockImplementationOnce(() => Promise.reject(storageCause)); + const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); + + assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); + assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); + assert.equal(storageError.partition, secondPartition); + assert.strictEqual(storageError.cause, storageCause); + assert.equal( + storageError.message, + `Failed to clear desktop preview browser storage for partition ${secondPartition}.`, + ); + assert.notInclude(storageError.message, storageCause.message); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); + } + + const cacheCause = new Error("cache clear failed"); + firstSession.clearCache.mockImplementationOnce(() => Promise.reject(cacheCause)); + const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); + + assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); + assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); + assert.equal(cacheError.partition, firstPartition); + assert.strictEqual(cacheError.cause, cacheCause); + assert.equal( + cacheError.message, + `Failed to clear the desktop preview browser cache for partition ${firstPartition}.`, + ); + assert.notInclude(cacheError.message, cacheCause.message); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); + } + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 7155b975f78..afa8dafe976 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -5,31 +5,87 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -export class BrowserSessionError extends Schema.TaggedErrorClass()( - "BrowserSessionError", +export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( + "BrowserSessionPartitionDerivationError", { - operation: Schema.Literals(["getPartition", "getSession", "clearCookies", "clearCache"]), + scope: Schema.String, + cause: Schema.instanceOf(PlatformError.PlatformError), + }, +) { + override get message(): string { + return `Failed to derive a desktop preview browser partition for scope ${this.scope}.`; + } +} + +export class BrowserSessionCreationError extends Schema.TaggedErrorClass()( + "BrowserSessionCreationError", + { + scope: Schema.String, + partition: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create a desktop preview browser session for scope ${this.scope} (partition ${this.partition}).`; + } +} + +export class BrowserSessionStorageClearError extends Schema.TaggedErrorClass()( + "BrowserSessionStorageClearError", + { + partition: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { - return `Desktop preview browser session operation failed: ${this.operation}`; + return `Failed to clear desktop preview browser storage for partition ${this.partition}.`; } } +export class BrowserSessionCacheClearError extends Schema.TaggedErrorClass()( + "BrowserSessionCacheClearError", + { + partition: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to clear the desktop preview browser cache for partition ${this.partition}.`; + } +} + +export const BrowserSessionGetSessionError = Schema.Union([ + BrowserSessionPartitionDerivationError, + BrowserSessionCreationError, +]); +export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; +export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); + +export const BrowserSessionError = Schema.Union([ + BrowserSessionPartitionDerivationError, + BrowserSessionCreationError, + BrowserSessionStorageClearError, + BrowserSessionCacheClearError, +]); +export type BrowserSessionError = typeof BrowserSessionError.Type; +export const isBrowserSessionError = Schema.is(BrowserSessionError); + export class BrowserSession extends Context.Service< BrowserSession, { - readonly getPartition: (scope?: string) => Effect.Effect; + readonly getPartition: ( + scope?: string, + ) => Effect.Effect; readonly isPartition: (partition: string) => boolean; - readonly getSession: (scope?: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; + readonly getSession: (scope?: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; } >()("@t3tools/desktop/preview/BrowserSession") {} @@ -38,11 +94,15 @@ export const make = Effect.gen(function* BrowserSessionMake() { const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") { - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(scope)) - .pipe( - Effect.mapError((cause) => new BrowserSessionError({ operation: "getPartition", cause })), - ); + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe( + Effect.mapError( + (cause) => + new BrowserSessionPartitionDerivationError({ + scope, + cause, + }), + ), + ); return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; }); @@ -67,7 +127,12 @@ export const make = Effect.gen(function* BrowserSessionMake() { next.set(partition, browserSession); return [browserSession, next] as const; }, - catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }), + catch: (cause) => + new BrowserSessionCreationError({ + scope, + partition, + cause, + }), }); }); }); @@ -79,13 +144,17 @@ export const make = Effect.gen(function* BrowserSessionMake() { clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.values()].map((browserSession) => + [...sessions.entries()].map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], }), - catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), + catch: (cause) => + new BrowserSessionStorageClearError({ + partition, + cause, + }), }), ), { concurrency: "unbounded", discard: true }, @@ -94,10 +163,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { clearCache: Effect.fn("BrowserSession.clearCache")(function* () { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.values()].map((browserSession) => + [...sessions.entries()].map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearCache(), - catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), + catch: (cause) => + new BrowserSessionCacheClearError({ + partition, + cause, + }), }), ), { concurrency: "unbounded", discard: true }, From abc253d97dc1d976cf15e69276f2ac7375b26310 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:12:41 -0700 Subject: [PATCH 163/257] [codex] Preserve native SQLite failure context (#3428) Co-authored-by: codex --- apps/server/src/persistence/Layers/Sqlite.ts | 3 +- .../src/persistence/NodeSqliteClient.test.ts | 22 +++++ .../src/persistence/NodeSqliteClient.ts | 98 +++++++++++++++---- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 3bc1ec4d2d2..dfd338b7159 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; @@ -13,7 +14,7 @@ type RuntimeSqliteLayerConfig = { }; type Loader = { - layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; + layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; }; const defaultSqliteClientLoaders = { bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), diff --git a/apps/server/src/persistence/NodeSqliteClient.test.ts b/apps/server/src/persistence/NodeSqliteClient.test.ts index 43023abf60a..ce52c36d84c 100644 --- a/apps/server/src/persistence/NodeSqliteClient.test.ts +++ b/apps/server/src/persistence/NodeSqliteClient.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqliteClient from "./NodeSqliteClient.ts"; @@ -27,4 +28,25 @@ layer("NodeSqliteClient", (it) => { assert.equal(values[1]?.[1], "beta"); }), ); + + it.effect("returns a typed failure when an unprepared statement cannot be prepared", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const error = yield* Effect.flip(sql.unsafe("SELECT FROM").unprepared); + + assert.equal(error._tag, "SqlError"); + assert.equal(error.reason.operation, "prepare"); + }), + ); }); + +it.effect("returns a typed failure when the database cannot be opened", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Layer.build(SqliteClient.layer({ filename: "\0" })).pipe(Effect.scoped), + ); + + assert.equal(error._tag, "SqlError"); + assert.equal(error.reason.operation, "open"); + }), +); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index f3d03e1c695..16d5762a1fe 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Context from "effect/Context"; @@ -45,6 +46,27 @@ export interface SqliteMemoryClientConfig extends Omit< "filename" | "readonly" > {} +export class UnsupportedNodeSqliteVersionError extends Schema.TaggedErrorClass()( + "UnsupportedNodeSqliteVersionError", + { + nodeVersion: Schema.String, + requirement: Schema.String, + }, +) { + override get message(): string { + return `Node.js ${this.nodeVersion} is missing required node:sqlite APIs. Upgrade to ${this.requirement}.`; + } +} + +export class UnsupportedNodeSqliteOperationError extends Schema.TaggedErrorClass()( + "UnsupportedNodeSqliteOperationError", + {}, +) { + override get message(): string { + return "Node SQLite does not support executeStream."; + } +} + /** * Verify that the current Node.js version includes the `node:sqlite` APIs * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added @@ -60,8 +82,10 @@ const checkNodeSqliteCompat = () => { if (!supported) { return Effect.die( - `Node.js ${process.versions.node} is missing required node:sqlite APIs ` + - `(StatementSync.columns). Upgrade to Node.js >=22.16, >=23.11, or >=24.`, + new UnsupportedNodeSqliteVersionError({ + nodeVersion: process.versions.node, + requirement: "Node.js >=22.16, >=23.11, or >=24", + }), ); } return Effect.void; @@ -70,7 +94,7 @@ const checkNodeSqliteCompat = () => { const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( options: SqliteClientConfig, openDatabase: () => NodeSqlite.DatabaseSync, -): Effect.fn.Return { +): Effect.fn.Return { yield* checkNodeSqliteCompat(); const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); @@ -80,10 +104,28 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( const makeConnection = Effect.gen(function* () { const scope = yield* Effect.scope; - const db = openDatabase(); + const db = yield* Effect.try({ + try: openDatabase, + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to open database", + operation: "open", + }), + }), + }); yield* Scope.addFinalizer( scope, - Effect.sync(() => db.close()), + Effect.try({ + try: () => db.close(), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to close database", + operation: "close", + }), + }), + }).pipe(Effect.orDie), ); const statementReaderCache = new WeakMap(); @@ -119,8 +161,8 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( raw: boolean, ) => Effect.withFiber, SqlError>((fiber) => { - statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); try { + statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); if (hasRows(statement)) { return Effect.succeed(statement.all(...(params as any))); } @@ -166,11 +208,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( }), }), (statement) => - Effect.sync(() => { - if (hasRows(statement)) { - statement.setReturnArrays(false); - } - }), + Effect.try({ + try: () => { + if (hasRows(statement)) { + statement.setReturnArrays(false); + } + }, + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to reset statement result mode", + operation: "resetResultMode", + }), + }), + }).pipe(Effect.orDie), ); return identity({ @@ -184,11 +235,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( return runValues(sql, params); }, executeUnprepared(sql, params, rowTransform) { - const effect = runStatement(db.prepare(sql), params ?? [], false); + const effect = Effect.try({ + try: () => db.prepare(sql), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to prepare statement", + operation: "prepare", + }), + }), + }).pipe(Effect.flatMap((statement) => runStatement(statement, params ?? [], false))); return rowTransform ? Effect.map(effect, rowTransform) : effect; }, executeStream(_sql, _params) { - return Stream.die("executeStream not implemented"); + return Stream.die(new UnsupportedNodeSqliteOperationError()); }, }); }); @@ -220,7 +280,7 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( const make = ( options: SqliteClientConfig, -): Effect.Effect => +): Effect.Effect => makeWithDatabase( options, () => @@ -232,7 +292,7 @@ const make = ( const makeMemory = ( config: SqliteMemoryClientConfig = {}, -): Effect.Effect => +): Effect.Effect => makeWithDatabase( { ...config, @@ -249,13 +309,15 @@ const makeMemory = ( export const layerConfig = ( config: Config.Wrap, -): Layer.Layer => +): Layer.Layer => Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe( Layer.provide(Reactivity.layer), ); -export const layer = (config: SqliteClientConfig): Layer.Layer => +export const layer = (config: SqliteClientConfig): Layer.Layer => Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)); -export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer => +export const layerMemory = ( + config: SqliteMemoryClientConfig = {}, +): Layer.Layer => Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer)); From c7b375e9b0f0581a977a2745e3afbf5732aecb15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:23:46 -0700 Subject: [PATCH 164/257] [codex] Structure Electron window failures (#3276) Co-authored-by: codex --- .../src/electron/ElectronWindow.test.ts | 204 ++++++++++++++- apps/desktop/src/electron/ElectronWindow.ts | 235 ++++++++++++++---- 2 files changed, 385 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index cc6c6484245..b59f8572739 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -1,26 +1,39 @@ import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; -const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ - appFocusMock: vi.fn(), - getAllWindowsMock: vi.fn(), -})); +const { appFocusMock, browserWindowMock, getAllWindowsMock, getFocusedWindowMock } = vi.hoisted( + () => ({ + appFocusMock: vi.fn(), + browserWindowMock: vi.fn(function BrowserWindowMock() {}), + getAllWindowsMock: vi.fn(), + getFocusedWindowMock: vi.fn(), + }), +); vi.mock("electron", () => ({ app: { focus: appFocusMock, }, - BrowserWindow: { + BrowserWindow: Object.assign(browserWindowMock, { getAllWindows: getAllWindowsMock, - }, + getFocusedWindow: getFocusedWindowMock, + }), })); import * as ElectronWindow from "./ElectronWindow.ts"; -function makeBrowserWindow(input: { readonly destroyed: boolean }) { +const TestLayer = ElectronWindow.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + +function makeBrowserWindow(input: { readonly id: number; readonly destroyed: boolean }) { return { + id: input.id, isDestroyed: vi.fn(() => input.destroyed), } as unknown as Electron.BrowserWindow; } @@ -28,13 +41,78 @@ function makeBrowserWindow(input: { readonly destroyed: boolean }) { describe("ElectronWindow", () => { beforeEach(() => { appFocusMock.mockReset(); + browserWindowMock.mockReset(); getAllWindowsMock.mockReset(); + getFocusedWindowMock.mockReset(); }); + it.effect("preserves schema-safe creation context and the Electron cause", () => + Effect.gen(function* () { + const cause = new Error("native BrowserWindow construction failed"); + browserWindowMock.mockImplementationOnce(function BrowserWindowFailure() { + throw cause; + }); + const options = { + title: "T3 Code", + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + modal: false, + frame: true, + transparent: false, + backgroundColor: "#101010", + icon: {} as Electron.NativeImage, + webPreferences: { + preload: "/tmp/preload.js", + partition: "persist:t3code-preview-test", + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webviewTag: true, + spellcheck: true, + }, + } satisfies Electron.BrowserWindowConstructorOptions; + const electronWindow = yield* ElectronWindow.ElectronWindow; + + const error = yield* electronWindow.create(options).pipe(Effect.flip); + + assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); + assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); + assert.deepEqual(error.options, { + title: "T3 Code", + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + modal: false, + frame: true, + transparent: false, + backgroundColor: "#101010", + webPreferences: { + preload: "/tmp/preload.js", + partition: "persist:t3code-preview-test", + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webviewTag: true, + }, + }); + assert.isFalse("icon" in error.options); + assert.isFalse("spellcheck" in error.options.webPreferences); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to create Electron BrowserWindow "T3 Code" (1100x780).'); + assert.notInclude(error.message, cause.message); + assert.deepEqual(browserWindowMock.mock.calls, [[options]]); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("skips windows destroyed before appearance sync runs", () => Effect.gen(function* () { - const liveWindow = makeBrowserWindow({ destroyed: false }); - const destroyedWindow = makeBrowserWindow({ destroyed: true }); + const liveWindow = makeBrowserWindow({ id: 1, destroyed: false }); + const destroyedWindow = makeBrowserWindow({ id: 2, destroyed: true }); getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); const syncedWindows: Electron.BrowserWindow[] = []; @@ -46,6 +124,112 @@ describe("ElectronWindow", () => { ); assert.deepEqual(syncedWindows, [liveWindow]); - }).pipe(Effect.provide(ElectronWindow.layer)), + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves window enumeration failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("window enumeration failed"); + getAllWindowsMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.currentMainOrFirst); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "list-windows"); + assert.equal(error.platform, "linux"); + assert.isNull(error.windowId); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves reveal failures with the target window", () => + Effect.gen(function* () { + const cause = new Error("window restore failed"); + const window = { + id: 41, + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => true), + restore: vi.fn(() => { + throw cause; + }), + } as unknown as Electron.BrowserWindow; + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.reveal(window)); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "reveal-window"); + assert.equal(error.windowId, 41); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves message delivery failures with window and channel context", () => + Effect.gen(function* () { + const cause = new Error("renderer send failed"); + const window = { + id: 42, + isDestroyed: vi.fn(() => false), + webContents: { + send: vi.fn(() => { + throw cause; + }), + }, + } as unknown as Electron.BrowserWindow; + getAllWindowsMock.mockReturnValueOnce([window]); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.sendAll("desktop:update", { ready: true })); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "send-window-message"); + assert.equal(error.windowId, 42); + assert.equal(error.channel, "desktop:update"); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves destroy failures with the target window", () => + Effect.gen(function* () { + const cause = new Error("window destroy failed"); + const window = { + id: 43, + destroy: vi.fn(() => { + throw cause; + }), + } as unknown as Electron.BrowserWindow; + getAllWindowsMock.mockReturnValueOnce([window]); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.destroyAll); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "destroy-window"); + assert.equal(error.windowId, 43); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 0bf98a9610e..dacb2eebb47 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -8,14 +8,69 @@ import * as Schema from "effect/Schema"; import * as Electron from "electron"; +const ElectronWindowCreateOptions = Schema.Struct({ + title: Schema.NullOr(Schema.String), + width: Schema.NullOr(Schema.Number), + height: Schema.NullOr(Schema.Number), + minWidth: Schema.NullOr(Schema.Number), + minHeight: Schema.NullOr(Schema.Number), + show: Schema.NullOr(Schema.Boolean), + modal: Schema.NullOr(Schema.Boolean), + frame: Schema.NullOr(Schema.Boolean), + transparent: Schema.NullOr(Schema.Boolean), + backgroundColor: Schema.NullOr(Schema.String), + webPreferences: Schema.Struct({ + preload: Schema.NullOr(Schema.String), + partition: Schema.NullOr(Schema.String), + sandbox: Schema.NullOr(Schema.Boolean), + contextIsolation: Schema.NullOr(Schema.Boolean), + nodeIntegration: Schema.NullOr(Schema.Boolean), + webviewTag: Schema.NullOr(Schema.Boolean), + }), +}); + +const ElectronWindowOperation = Schema.Literals([ + "list-windows", + "get-focused-window", + "inspect-window", + "reveal-window", + "send-window-message", + "destroy-window", +]); + export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowCreateError", { + options: ElectronWindowCreateOptions, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const title = this.options.title === null ? "" : ` "${this.options.title}"`; + const dimensions = + this.options.width === null || this.options.height === null + ? "" + : ` (${this.options.width}x${this.options.height})`; + return `Failed to create Electron BrowserWindow${title}${dimensions}.`; + } +} + +export const isElectronWindowCreateError = Schema.is(ElectronWindowCreateError); + +export class ElectronWindowOperationError extends Schema.TaggedErrorClass()( + "ElectronWindowOperationError", + { + operation: ElectronWindowOperation, + platform: Schema.String, + windowId: Schema.NullOr(Schema.Number), + channel: Schema.NullOr(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to create Electron BrowserWindow."; + const window = this.windowId === null ? "" : ` for window ${this.windowId}`; + const channel = this.channel === null ? "" : ` on channel ${JSON.stringify(this.channel)}`; + return `Electron window operation ${JSON.stringify(this.operation)} failed${window}${channel} on ${this.platform}.`; } } @@ -43,9 +98,38 @@ export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; const mainWindowRef = yield* Ref.make>(Option.none()); - const liveMain = Ref.get(mainWindowRef).pipe( - Effect.map(Option.filter((value) => !value.isDestroyed())), - ); + const listWindows = Effect.try({ + try: () => Electron.BrowserWindow.getAllWindows(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "list-windows", + platform, + windowId: null, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + + const isWindowDestroyed = (window: Electron.BrowserWindow) => + Effect.try({ + try: () => window.isDestroyed(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "inspect-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + + const liveMain = Effect.gen(function* () { + const main = yield* Ref.get(mainWindowRef); + if (Option.isNone(main) || (yield* isWindowDestroyed(main.value))) { + return Option.none(); + } + return main; + }); const currentMainOrFirst = Effect.gen(function* () { const main = yield* liveMain; @@ -53,27 +137,60 @@ export const make = Effect.gen(function* () { return main; } - return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( - Option.filter((window) => !window.isDestroyed()), - ); + const first = Option.fromNullishOr((yield* listWindows)[0] ?? null); + if (Option.isNone(first) || (yield* isWindowDestroyed(first.value))) { + return Option.none(); + } + return first; }); - const focusedMainOrFirst = Effect.sync(() => - Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( - Option.filter((window) => !window.isDestroyed()), - ), - ).pipe( - Effect.flatMap((focused) => - Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst, - ), - ); + const focusedMainOrFirst = Effect.gen(function* () { + const focused = yield* Effect.try({ + try: () => Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "get-focused-window", + platform, + windowId: null, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + if (Option.isSome(focused) && !(yield* isWindowDestroyed(focused.value))) { + return focused; + } + return yield* currentMainOrFirst; + }); return ElectronWindow.of({ - create: (options) => - Effect.try({ + create: (options) => { + const webPreferences = options.webPreferences; + const diagnosticOptions = { + title: options.title ?? null, + width: options.width ?? null, + height: options.height ?? null, + minWidth: options.minWidth ?? null, + minHeight: options.minHeight ?? null, + show: options.show ?? null, + modal: options.modal ?? null, + frame: options.frame ?? null, + transparent: options.transparent ?? null, + backgroundColor: options.backgroundColor ?? null, + webPreferences: { + preload: webPreferences?.preload ?? null, + partition: webPreferences?.partition ?? null, + sandbox: webPreferences?.sandbox ?? null, + contextIsolation: webPreferences?.contextIsolation ?? null, + nodeIntegration: webPreferences?.nodeIntegration ?? null, + webviewTag: webPreferences?.webviewTag ?? null, + }, + } satisfies typeof ElectronWindowCreateOptions.Type; + + return Effect.try({ try: () => new Electron.BrowserWindow(options), - catch: (cause) => new ElectronWindowCreateError({ cause }), - }), + catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), + }); + }, main: liveMain, currentMainOrFirst, focusedMainOrFirst, @@ -89,45 +206,75 @@ export const make = Effect.gen(function* () { return Option.none(); }), reveal: (window) => - Effect.sync(() => { - if (window.isDestroyed()) { - return; - } + Effect.try({ + try: () => { + if (window.isDestroyed()) { + return; + } - if (window.isMinimized()) { - window.restore(); - } + if (window.isMinimized()) { + window.restore(); + } - if (!window.isVisible()) { - window.show(); - } + if (!window.isVisible()) { + window.show(); + } - if (platform === "darwin") { - Electron.app.focus({ steal: true }); - } + if (platform === "darwin") { + Electron.app.focus({ steal: true }); + } - window.focus(); - }), + window.focus(); + }, + catch: (cause) => + new ElectronWindowOperationError({ + operation: "reveal-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie), sendAll: (channel, ...args) => - Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { - if (window.isDestroyed()) { + Effect.gen(function* () { + for (const window of yield* listWindows) { + if (yield* isWindowDestroyed(window)) { continue; } - window.webContents.send(channel, ...args); + yield* Effect.try({ + try: () => window.webContents.send(channel, ...args), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "send-window-message", + platform, + windowId: window.id, + channel, + cause, + }), + }).pipe(Effect.orDie); } }), - destroyAll: Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { - window.destroy(); + destroyAll: Effect.gen(function* () { + for (const window of yield* listWindows) { + yield* Effect.try({ + try: () => window.destroy(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "destroy-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie); } }), syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( sync: (window: Electron.BrowserWindow) => Effect.Effect, ) { - const windows = Electron.BrowserWindow.getAllWindows(); + const windows = yield* listWindows; for (const window of windows) { - if (window.isDestroyed()) { + if (yield* isWindowDestroyed(window)) { continue; } yield* sync(window); From 4407a5a6b716b1d37bed917345d79f627a42f59e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:24:32 -0700 Subject: [PATCH 165/257] [codex] Structure Codex shadow home errors (#3262) Co-authored-by: codex --- .../provider/Drivers/CodexHomeLayout.test.ts | 60 +++- .../src/provider/Drivers/CodexHomeLayout.ts | 296 +++++++++++++----- 2 files changed, 273 insertions(+), 83 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 12e98293b12..ec78b1665ef 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -3,11 +3,13 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; import { - CodexShadowHomeError, + CodexShadowHomeEntryConflictError, + CodexShadowHomePathConflictError, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; @@ -184,7 +186,14 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - expect(error).toBeInstanceOf(CodexShadowHomeError); + expect(error).toBeInstanceOf(CodexShadowHomePathConflictError); + expect(error).toMatchObject({ + sharedHomePath: sharedHome, + effectiveHomePath: sharedHome, + }); + expect(error.message).toBe( + `Codex shadow home path '${sharedHome}' must be different from the shared home path '${sharedHome}'.`, + ); }), ); @@ -206,7 +215,52 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - expect(error.detail).toContain("already exists and is not a symlink"); + expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError); + expect(error).toMatchObject({ + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + entryName: "config.toml", + linkPath: path.join(shadowHome, "config.toml"), + targetPath: path.join(sharedHome, "config.toml"), + }); + expect(error.message).toBe( + `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, + ); + }), + ); + + it.effect("preserves filesystem operation, paths, and cause", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedRoot = yield* makeTempDir("t3code-codex-shared-root-"); + const sharedHome = path.join(sharedRoot, "shared-home"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(sharedHome, "not a directory\n"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + + expect(error._tag).toBe("CodexShadowHomeFileSystemError"); + if (error._tag !== "CodexShadowHomeFileSystemError") { + return expect.fail("Expected CodexShadowHomeFileSystemError"); + } + expect(error).toMatchObject({ + operation: "makeDirectory", + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + }); + expect(error.path.startsWith(sharedHome)).toBe(true); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect(error.message).toBe( + `Codex shadow home filesystem operation 'makeDirectory' failed for '${error.path}'.`, + ); }), ); }); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 5a7132224ef..d2d09e9d844 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -63,18 +63,71 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi }; }); -export class CodexShadowHomeError extends Schema.TaggedErrorClass()( - "CodexShadowHomeError", +const CodexShadowHomeContext = { + sharedHomePath: Schema.String, + effectiveHomePath: Schema.String, +}; + +export class CodexShadowHomeFileSystemError extends Schema.TaggedErrorClass()( + "CodexShadowHomeFileSystemError", + { + ...CodexShadowHomeContext, + operation: Schema.Literals(["readLink", "makeDirectory", "readDirectory", "remove", "symlink"]), + path: Schema.String, + targetPath: Schema.optional(Schema.String), + entryName: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const target = this.targetPath === undefined ? "" : ` to '${this.targetPath}'`; + return `Codex shadow home filesystem operation '${this.operation}' failed for '${this.path}'${target}.`; + } +} + +export class CodexShadowHomePathConflictError extends Schema.TaggedErrorClass()( + "CodexShadowHomePathConflictError", + CodexShadowHomeContext, +) { + override get message(): string { + return `Codex shadow home path '${this.effectiveHomePath}' must be different from the shared home path '${this.sharedHomePath}'.`; + } +} + +export class CodexShadowHomeEntryConflictError extends Schema.TaggedErrorClass()( + "CodexShadowHomeEntryConflictError", { - detail: Schema.String, - cause: Schema.optional(Schema.Unknown), + ...CodexShadowHomeContext, + entryName: Schema.String, + linkPath: Schema.String, + targetPath: Schema.String, }, ) { override get message(): string { - return this.detail; + return `Cannot create Codex shadow home entry '${this.entryName}' because '${this.linkPath}' already exists and is not a symlink.`; } } -const isCodexShadowHomeError = Schema.is(CodexShadowHomeError); + +export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedErrorClass()( + "CodexShadowHomePrivateEntrySymlinkError", + { + ...CodexShadowHomeContext, + entryName: Schema.String, + path: Schema.String, + }, +) { + override get message(): string { + return `Codex shadow home private entry '${this.entryName}' at '${this.path}' must be a real file, not a symlink.`; + } +} + +export const CodexShadowHomeError = Schema.Union([ + CodexShadowHomeFileSystemError, + CodexShadowHomePathConflictError, + CodexShadowHomeEntryConflictError, + CodexShadowHomePrivateEntrySymlinkError, +]); +export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; type LinkState = | { @@ -88,21 +141,6 @@ type LinkState = readonly target: string; }; -function toShadowHomeError(cause: unknown): CodexShadowHomeError { - return isCodexShadowHomeError(cause) - ? cause - : new CodexShadowHomeError({ - detail: "Failed to materialize Codex shadow home.", - cause, - }); -} - -function normalizeShadowHomeError( - effect: Effect.Effect, -): Effect.Effect { - return effect.pipe(Effect.mapError(toShadowHomeError)); -} - function isNotSymlinkError(error: PlatformError.PlatformError): boolean { const cause = error.reason.cause; return ( @@ -114,78 +152,151 @@ function isNotSymlinkError(error: PlatformError.PlatformError): boolean { ); } -const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* ( - fileSystem: FileSystem.FileSystem, - linkPath: string, -): Effect.fn.Return { - return yield* fileSystem.readLink(linkPath).pipe( +const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; + readonly entryName: string; + readonly linkPath: string; +}): Effect.fn.Return { + return yield* input.fileSystem.readLink(input.linkPath).pipe( Effect.map((target): LinkState => ({ _tag: "Symlink", target })), - Effect.catch((error) => { - if (error.reason._tag === "NotFound") { - return Effect.succeed({ _tag: "Missing" }); - } - if (isNotSymlinkError(error)) { - return Effect.succeed({ _tag: "NotSymlink" }); - } - return Effect.fail(toShadowHomeError(error)); + Effect.catchTags({ + PlatformError: (cause) => { + if (cause.reason._tag === "NotFound") { + return Effect.succeed({ _tag: "Missing" }); + } + if (isNotSymlinkError(cause)) { + return Effect.succeed({ _tag: "NotSymlink" }); + } + return new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "readLink", + path: input.linkPath, + entryName: input.entryName, + cause, + }); + }, }), ); }); const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; - readonly shadowPath: string; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; readonly entryName: string; }): Effect.fn.Return { const path = yield* Path.Path; - const privatePath = path.join(input.shadowPath, input.entryName); - const state = yield* readLinkState(input.fileSystem, privatePath); + const privatePath = path.join(input.effectiveHomePath, input.entryName); + const state = yield* readLinkState({ + ...input, + linkPath: privatePath, + }); if (state._tag === "Symlink") { - yield* normalizeShadowHomeError(input.fileSystem.remove(privatePath)); + yield* input.fileSystem.remove(privatePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: privatePath, + entryName: input.entryName, + cause, + }), + }), + ); } }); const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; - readonly shadowPath: string; - readonly sharedPath: string; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; readonly entryName: string; }): Effect.fn.Return { const path = yield* Path.Path; - const target = path.join(input.sharedPath, input.entryName); - const link = path.join(input.shadowPath, input.entryName); - const state = yield* readLinkState(input.fileSystem, link); + const target = path.join(input.sharedHomePath, input.entryName); + const link = path.join(input.effectiveHomePath, input.entryName); + const state = yield* readLinkState({ + ...input, + linkPath: link, + }); if (state._tag === "NotSymlink") { - return yield* new CodexShadowHomeError({ - detail: `Cannot create Codex shadow home because '${link}' already exists and is not a symlink.`, + return yield* new CodexShadowHomeEntryConflictError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName: input.entryName, + linkPath: link, + targetPath: target, }); } + const createLink = input.fileSystem.symlink(target, link).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "symlink", + path: link, + targetPath: target, + entryName: input.entryName, + cause, + }), + }), + ); + if (state._tag === "Missing") { - return yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + return yield* createLink; } const resolvedExisting = path.resolve(path.dirname(link), state.target); if (resolvedExisting !== target) { - yield* normalizeShadowHomeError(input.fileSystem.remove(link)); - yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + yield* input.fileSystem.remove(link).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + yield* createLink; } }); -const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")(function* ( - fileSystem: FileSystem.FileSystem, - shadowPath: string, -): Effect.fn.Return { - const path = yield* Path.Path; - const authPath = path.join(shadowPath, "auth.json"); - const state = yield* readLinkState(fileSystem, authPath); - if (state._tag === "Symlink") { - return yield* new CodexShadowHomeError({ - detail: `Codex shadow auth file '${authPath}' must be a real file, not a symlink.`, +const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; + }): Effect.fn.Return { + const path = yield* Path.Path; + const entryName = "auth.json"; + const authPath = path.join(input.effectiveHomePath, entryName); + const state = yield* readLinkState({ + ...input, + entryName, + linkPath: authPath, }); - } -}); + if (state._tag === "Symlink") { + return yield* new CodexShadowHomePrivateEntrySymlinkError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName, + path: authPath, + }); + } + }, +); export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( layout: CodexHomeLayout, @@ -194,31 +305,51 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" const effectiveHomePath = layout.effectiveHomePath; if (!effectiveHomePath) return; if (layout.sharedHomePath === effectiveHomePath) { - return yield* new CodexShadowHomeError({ - detail: "Codex shadow home path must be different from the shared home path.", + return yield* new CodexShadowHomePathConflictError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, }); } const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* normalizeShadowHomeError( - Effect.all( - [ - fileSystem.makeDirectory(layout.sharedHomePath, { recursive: true }), - fileSystem.makeDirectory(effectiveHomePath, { recursive: true }), - ...KNOWN_SHARED_DIRECTORIES.map((directory) => - fileSystem.makeDirectory(path.join(layout.sharedHomePath, directory), { - recursive: true, + const makeDirectory = (directoryPath: string) => + fileSystem.makeDirectory(directoryPath, { recursive: true }).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "makeDirectory", + path: directoryPath, + cause, }), - ), - ], - { concurrency: "unbounded" }, - ), + }), + ); + + yield* Effect.all( + [ + makeDirectory(layout.sharedHomePath), + makeDirectory(effectiveHomePath), + ...KNOWN_SHARED_DIRECTORIES.map((directory) => + makeDirectory(path.join(layout.sharedHomePath, directory)), + ), + ], + { concurrency: "unbounded" }, ); - const sharedEntryNames = yield* normalizeShadowHomeError( - fileSystem.readDirectory(layout.sharedHomePath), + const sharedEntryNames = yield* fileSystem.readDirectory(layout.sharedHomePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "readDirectory", + path: layout.sharedHomePath, + cause, + }), + }), ); const entries = new Set(KNOWN_SHARED_DIRECTORIES); for (const entryName of sharedEntryNames) { @@ -234,7 +365,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" ? Effect.void : removePrivateSymlink({ fileSystem, - shadowPath: effectiveHomePath, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, entryName, }), { discard: true }, @@ -248,15 +380,19 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" } return ensureSymlink({ fileSystem, - shadowPath: effectiveHomePath, - sharedPath: layout.sharedHomePath, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, entryName, }); }, { discard: true }, ); - yield* ensureShadowAuthIsPrivate(fileSystem, effectiveHomePath); + yield* ensureShadowAuthIsPrivate({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + }); }); export function codexContinuationIdentity(layout: CodexHomeLayout) { From 4c16c66368c934a3f23794fc4826a2c77fe9f323 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:25:16 -0700 Subject: [PATCH 166/257] [codex] Structure ACP transport errors (#3251) Co-authored-by: codex --- packages/effect-acp/src/_internal/shared.ts | 46 +++-- packages/effect-acp/src/_internal/stdio.ts | 2 +- packages/effect-acp/src/agent.ts | 94 ++++++----- packages/effect-acp/src/client.test.ts | 6 +- packages/effect-acp/src/client.ts | 37 ++-- packages/effect-acp/src/errors.test.ts | 144 ++++++++++++++++ packages/effect-acp/src/errors.ts | 178 +++++++++++++++++++- packages/effect-acp/src/protocol.test.ts | 136 ++++++++++++++- packages/effect-acp/src/protocol.ts | 82 ++++----- packages/effect-acp/src/terminal.ts | 20 --- 10 files changed, 593 insertions(+), 152 deletions(-) create mode 100644 packages/effect-acp/src/errors.test.ts diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index 937d931c404..7e43bbf8831 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -1,30 +1,29 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { RpcClientError } from "effect/unstable/rpc"; import * as AcpSchema from "../_generated/schema.gen.ts"; import * as AcpError from "../errors.ts"; const isError = Schema.is(AcpSchema.Error); -const isAcpRequestError = Schema.is(AcpError.AcpRequestError); - -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); export const callRpc = ( + method: string, effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTag("RpcClientError", (error) => - Effect.fail( - new AcpError.AcpTransportError({ - detail: error.message, - cause: error, - }), - ), - ), Effect.catchIf(isError, (error) => Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)), ), + Effect.catchTags({ + RpcClientError: (cause) => + Effect.fail( + new AcpError.AcpTransportError({ + operation: "call-rpc", + method, + cause, + }), + ), + }), ); export const runHandler = Effect.fnUntraced(function* ( @@ -37,9 +36,7 @@ export const runHandler = Effect.fnUntraced(function* ( } return yield* handler(payload).pipe( Effect.mapError((error) => - isAcpRequestError(error) - ? error.toProtocolError() - : AcpError.AcpRequestError.internalError(error.message).toProtocolError(), + AcpError.AcpRequestError.fromCoreHandlerError(error, method).toProtocolError(), ), ); }); @@ -51,12 +48,7 @@ export function decodeExtRequestRegistration( ) { return (params: unknown): Effect.Effect => Schema.decodeUnknownEffect(payload)(params).pipe( - Effect.mapError((error) => - AcpError.AcpRequestError.invalidParams( - `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, - { issue: error.issue }, - ), - ), + Effect.mapError((error) => AcpError.AcpRequestError.invalidExtensionPayload(method, error)), Effect.flatMap((decoded) => handler(decoded)), ); } @@ -68,12 +60,12 @@ export function decodeExtNotificationRegistration( ) { return (params: unknown): Effect.Effect => Schema.decodeUnknownEffect(payload)(params).pipe( - Effect.mapError( - (error) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`, - cause: error, - }), + Effect.mapError((error) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + method, + error, + ), ), Effect.flatMap((decoded) => handler(decoded)), ); diff --git a/packages/effect-acp/src/_internal/stdio.ts b/packages/effect-acp/src/_internal/stdio.ts index 8ddb4d37d0f..393a1c591cb 100644 --- a/packages/effect-acp/src/_internal/stdio.ts +++ b/packages/effect-acp/src/_internal/stdio.ts @@ -50,7 +50,7 @@ export const makeTerminationError = ( Effect.match(handle.exitCode, { onFailure: (cause) => new AcpError.AcpTransportError({ - detail: "Failed to determine ACP process exit status", + operation: "read-process-exit-status", cause, }), onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }), diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 5cad53c3d12..307028b0a80 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -288,12 +288,12 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( notification.method === AGENT_METHODS.session_cancel ) { return decodeCancelNotification(notification.params).pipe( - Effect.mapError( - (error) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${AGENT_METHODS.session_cancel} notification payload`, - cause: error, - }), + Effect.mapError((error) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + AGENT_METHODS.session_cancel, + error, + ), ), Effect.flatMap((decoded) => Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }), @@ -376,41 +376,55 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( }, client: { requestPermission: (payload) => - callRpc(rpc[CLIENT_METHODS.session_request_permission](payload)), - elicit: (payload) => callRpc(rpc[CLIENT_METHODS.session_elicitation](payload)), - readTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_read_text_file](payload)), - writeTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_write_text_file](payload)), + callRpc( + CLIENT_METHODS.session_request_permission, + rpc[CLIENT_METHODS.session_request_permission](payload), + ), + elicit: (payload) => + callRpc( + CLIENT_METHODS.session_elicitation, + rpc[CLIENT_METHODS.session_elicitation](payload), + ), + readTextFile: (payload) => + callRpc(CLIENT_METHODS.fs_read_text_file, rpc[CLIENT_METHODS.fs_read_text_file](payload)), + writeTextFile: (payload) => + callRpc(CLIENT_METHODS.fs_write_text_file, rpc[CLIENT_METHODS.fs_write_text_file](payload)), createTerminal: (payload) => - callRpc(rpc[CLIENT_METHODS.terminal_create](payload)).pipe( - Effect.map((response) => - AcpTerminal.makeTerminal({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - output: callRpc( - rpc[CLIENT_METHODS.terminal_output]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - waitForExit: callRpc( - rpc[CLIENT_METHODS.terminal_wait_for_exit]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - kill: callRpc( - rpc[CLIENT_METHODS.terminal_kill]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - release: callRpc( - rpc[CLIENT_METHODS.terminal_release]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - }), + callRpc(CLIENT_METHODS.terminal_create, rpc[CLIENT_METHODS.terminal_create](payload)).pipe( + Effect.map( + (response) => + ({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + output: callRpc( + CLIENT_METHODS.terminal_output, + rpc[CLIENT_METHODS.terminal_output]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + waitForExit: callRpc( + CLIENT_METHODS.terminal_wait_for_exit, + rpc[CLIENT_METHODS.terminal_wait_for_exit]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + kill: callRpc( + CLIENT_METHODS.terminal_kill, + rpc[CLIENT_METHODS.terminal_kill]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + release: callRpc( + CLIENT_METHODS.terminal_release, + rpc[CLIENT_METHODS.terminal_release]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + }) satisfies AcpTerminal.AcpTerminal, ), ), sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload), diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index aca87d45c62..c732f80ef35 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -147,7 +147,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { ); it.effect( - "returns formatted invalid params when a typed extension request payload is wrong", + "returns structured invalid params without exposing values from typed extension request payloads", () => Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" }); @@ -213,8 +213,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { assert.fail("Expected prompt to fail for invalid typed extension payload"); } const rendered = Cause.pretty(result.cause); - assert.include(rendered, "Invalid x/typed_request payload:"); - assert.include(rendered, "Expected string, got 123"); + assert.include(rendered, "Invalid payload for ACP extension method 'x/typed_request'."); + assert.notInclude(rendered, "Expected string, got 123"); }), ); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 6f3d6a0c9f8..61b3d71b49d 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -462,19 +462,32 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( notify: transport.notify, }, agent: { - initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)), - authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)), - logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)), - createSession: (payload) => callRpc(rpc[AGENT_METHODS.session_new](payload)), - loadSession: (payload) => callRpc(rpc[AGENT_METHODS.session_load](payload)), - listSessions: (payload) => callRpc(rpc[AGENT_METHODS.session_list](payload)), - forkSession: (payload) => callRpc(rpc[AGENT_METHODS.session_fork](payload)), - resumeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_resume](payload)), - closeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_close](payload)), - setSessionModel: (payload) => callRpc(rpc[AGENT_METHODS.session_set_model](payload)), + initialize: (payload) => + callRpc(AGENT_METHODS.initialize, rpc[AGENT_METHODS.initialize](payload)), + authenticate: (payload) => + callRpc(AGENT_METHODS.authenticate, rpc[AGENT_METHODS.authenticate](payload)), + logout: (payload) => callRpc(AGENT_METHODS.logout, rpc[AGENT_METHODS.logout](payload)), + createSession: (payload) => + callRpc(AGENT_METHODS.session_new, rpc[AGENT_METHODS.session_new](payload)), + loadSession: (payload) => + callRpc(AGENT_METHODS.session_load, rpc[AGENT_METHODS.session_load](payload)), + listSessions: (payload) => + callRpc(AGENT_METHODS.session_list, rpc[AGENT_METHODS.session_list](payload)), + forkSession: (payload) => + callRpc(AGENT_METHODS.session_fork, rpc[AGENT_METHODS.session_fork](payload)), + resumeSession: (payload) => + callRpc(AGENT_METHODS.session_resume, rpc[AGENT_METHODS.session_resume](payload)), + closeSession: (payload) => + callRpc(AGENT_METHODS.session_close, rpc[AGENT_METHODS.session_close](payload)), + setSessionModel: (payload) => + callRpc(AGENT_METHODS.session_set_model, rpc[AGENT_METHODS.session_set_model](payload)), setSessionConfigOption: (payload) => - callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)), - prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)), + callRpc( + AGENT_METHODS.session_set_config_option, + rpc[AGENT_METHODS.session_set_config_option](payload), + ), + prompt: (payload) => + callRpc(AGENT_METHODS.session_prompt, rpc[AGENT_METHODS.session_prompt](payload)), cancel: (payload) => transport.notify(AGENT_METHODS.session_cancel, payload), }, handleRequestPermission: (handler) => diff --git a/packages/effect-acp/src/errors.test.ts b/packages/effect-acp/src/errors.test.ts new file mode 100644 index 00000000000..5187fabf5d2 --- /dev/null +++ b/packages/effect-acp/src/errors.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; + +import * as AcpSchema from "./_generated/schema.gen.ts"; +import { callRpc, runHandler } from "./_internal/shared.ts"; +import * as AcpError from "./errors.ts"; + +const decodeNestedNumberPayload = Schema.decodeUnknownEffect( + Schema.Struct({ profile: Schema.Struct({ token: Schema.Number }) }), +); +const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +describe("effect-acp errors", () => { + it.effect("retains RPC method and cause without deriving the message from the cause", () => { + const rootCause = new Error("connection details that must not become the public message"); + const failure = new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: rootCause.message, + cause: rootCause, + }), + }); + + return Effect.gen(function* () { + const error = yield* callRpc("session/new", Effect.fail(failure)).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpTransportError", + operation: "call-rpc", + method: "session/new", + cause: failure, + }); + expect(error.message).toBe("ACP transport operation call-rpc failed for method session/new."); + expect(error.message).not.toContain(rootCause.message); + }); + }); + + it.effect("preserves protocol request errors as request errors", () => { + const failure = AcpSchema.Error.make({ + code: -32602, + message: "Invalid params", + data: { field: "sessionId" }, + }); + + return Effect.gen(function* () { + const error = yield* callRpc("session/load", Effect.fail(failure)).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Invalid params", + data: { field: "sessionId" }, + }); + }); + }); + + it("does not expose legacy diagnostic detail as the transport message", () => { + const cause = new Error("connection refused at a private endpoint"); + const error = new AcpError.AcpTransportError({ + detail: cause.message, + cause, + }); + + expect(error.message).toBe("ACP transport operation failed."); + expect(error.cause).toBe(cause); + }); + + it("preserves structured extension handler failures behind stable request errors", () => { + const cause = new AcpError.AcpTransportError({ + operation: "read-input-stream", + cause: new Error("private transport diagnostics"), + }); + const error = AcpError.AcpRequestError.fromExtensionHandlerError(cause, "x/test"); + + expect(error).toMatchObject({ + code: -32603, + method: "x/test", + operation: "handle-extension-request", + cause, + }); + expect(error.message).toBe("ACP extension request handler failed for method 'x/test'"); + expect(error.message).not.toContain(cause.message); + }); + + it.effect("uses the structured mapper for core handler failures", () => { + const cause = new AcpError.AcpTransportError({ + operation: "read-input-stream", + cause: new Error("private transport diagnostics"), + }); + + return Effect.gen(function* () { + const error = yield* runHandler(() => Effect.fail(cause), {}, "fs/read_text_file").pipe( + Effect.flip, + ); + + expect(error).toMatchObject({ + code: -32603, + message: "ACP request handler failed for method 'fs/read_text_file'", + }); + expect(error.message).not.toContain(cause.message); + }); + }); + + it.effect("keeps invalid extension payload values only in the exact schema cause", () => + Effect.gen(function* () { + const secret = "acp-schema-payload-secret"; + const cause = yield* decodeNestedNumberPayload({ profile: { token: secret } }).pipe( + Effect.flip, + ); + const error = AcpError.AcpRequestError.invalidExtensionPayload("x/private", cause); + const { cause: directCause, ...directDiagnostics } = error; + + expect(directCause).toBe(cause); + expect(error).toMatchObject({ + method: "x/private", + operation: "decode-extension-request-payload", + maximumPathDepth: 2, + }); + expect(error.issueCount).toBeGreaterThan(0); + expect(error.issueKinds).toContain("Pointer"); + expect(error.message).toBe("Invalid payload for ACP extension method 'x/private'."); + expect(error.message).not.toContain(secret); + expect(encodeUnknownJson(directDiagnostics)).not.toContain(secret); + expect(encodeUnknownJson(error.toProtocolError())).not.toContain(secret); + + const protocolError = AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + "x/private", + cause, + ); + const { cause: protocolCause, ...protocolDiagnostics } = protocolError; + expect(protocolCause).toBe(cause); + expect(protocolError).toMatchObject({ + method: "x/private", + operation: "decode-notification-payload", + maximumPathDepth: 2, + }); + expect(protocolError.message).not.toContain(secret); + expect(encodeUnknownJson(protocolDiagnostics)).not.toContain(secret); + expect("detail" in protocolError).toBe(false); + }), + ); +}); diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 91668f841f9..b3c0dee6294 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -1,7 +1,77 @@ import * as Schema from "effect/Schema"; +import type * as SchemaIssue from "effect/SchemaIssue"; import * as AcpSchema from "./_generated/schema.gen.ts"; +export const AcpRequestOperation = Schema.Literals([ + "decode-extension-request-payload", + "handle-request", + "handle-extension-request", +]); +export type AcpRequestOperation = typeof AcpRequestOperation.Type; + +export const AcpSchemaIssueKind = Schema.Literals([ + "Filter", + "Encoding", + "Pointer", + "Composite", + "AnyOf", + "InvalidType", + "InvalidValue", + "MissingKey", + "UnexpectedKey", + "Forbidden", + "OneOf", +]); +export type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; + +export interface AcpSchemaIssueDiagnostics { + readonly issueCount: number; + readonly issueKinds: ReadonlyArray; + readonly maximumPathDepth: number; +} + +const schemaIssueDiagnostics = (root: SchemaIssue.Issue): AcpSchemaIssueDiagnostics => { + let issueCount = 0; + let maximumPathDepth = 0; + const issueKinds = new Set(); + + const visit = (issue: SchemaIssue.Issue, pathDepth: number): void => { + issueCount += 1; + issueKinds.add(issue._tag); + maximumPathDepth = Math.max(maximumPathDepth, pathDepth); + switch (issue._tag) { + case "Filter": + case "Encoding": + visit(issue.issue, pathDepth); + break; + case "Pointer": + visit(issue.issue, pathDepth + issue.path.length); + break; + case "Composite": + case "AnyOf": + for (const child of issue.issues) visit(child, pathDepth); + break; + } + }; + + visit(root, 0); + return { + issueCount, + issueKinds: [...issueKinds], + maximumPathDepth, + }; +}; + +export interface AcpRequestDiagnostics { + readonly method?: string; + readonly operation?: AcpRequestOperation; + readonly cause?: unknown; + readonly issueCount?: number; + readonly issueKinds?: ReadonlyArray; + readonly maximumPathDepth?: number; +} + export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { command: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -27,27 +97,68 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass()( "AcpProtocolParseError", { - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: AcpProtocolParseOperation, + method: Schema.optionalKey(Schema.String), + issueCount: Schema.optionalKey(Schema.Number), + issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)), + maximumPathDepth: Schema.optionalKey(Schema.Number), + cause: Schema.Defect(), }, ) { override get message() { - return `Failed to parse ACP protocol message: ${this.detail}`; + const method = this.method === undefined ? "" : ` for method '${this.method}'`; + return `ACP protocol operation '${this.operation}' failed${method}.`; + } + + static fromSchemaError( + operation: AcpProtocolParseOperation, + method: string, + cause: Schema.SchemaError, + ) { + return new AcpProtocolParseError({ + operation, + method, + ...schemaIssueDiagnostics(cause.issue), + cause, + }); } } export class AcpTransportError extends Schema.TaggedErrorClass()( "AcpTransportError", { - detail: Schema.String, + operation: Schema.optional( + Schema.Literals(["call-rpc", "read-input-stream", "read-process-exit-status"]), + ), + method: Schema.optional(Schema.String), + detail: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { override get message() { - return this.detail; + const method = this.method ? ` for method ${this.method}` : ""; + return this.operation + ? `ACP transport operation ${this.operation} failed${method}.` + : "ACP transport operation failed."; + } +} + +export class AcpInputStreamEndedError extends Schema.TaggedErrorClass()( + "AcpInputStreamEndedError", + {}, +) { + override get message() { + return "ACP input stream ended."; } } @@ -55,6 +166,12 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( code: AcpSchema.ErrorCode, errorMessage: Schema.String, data: Schema.optional(Schema.Unknown), + method: Schema.optionalKey(Schema.String), + operation: Schema.optionalKey(AcpRequestOperation), + issueCount: Schema.optionalKey(Schema.Number), + issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)), + maximumPathDepth: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), }) { override get message() { return this.errorMessage; @@ -68,6 +185,36 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( }); } + static fromCoreHandlerError(error: AcpError, method: string) { + if (error._tag === "AcpRequestError") { + return error; + } + return AcpRequestError.internalError( + `ACP request handler failed for method '${method}'`, + undefined, + { + method, + operation: "handle-request", + cause: error, + }, + ); + } + + static fromExtensionHandlerError(error: AcpError, method: string) { + if (error._tag === "AcpRequestError") { + return error; + } + return AcpRequestError.internalError( + `ACP extension request handler failed for method '${method}'`, + undefined, + { + method, + operation: "handle-extension-request", + cause: error, + }, + ); + } + static parseError(message = "Parse error", data?: unknown) { return new AcpRequestError({ code: -32700, @@ -99,11 +246,29 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( }); } - static internalError(message = "Internal error", data?: unknown) { + static invalidExtensionPayload(method: string, cause: Schema.SchemaError) { + const diagnostics = schemaIssueDiagnostics(cause.issue); + return new AcpRequestError({ + code: -32602, + errorMessage: `Invalid payload for ACP extension method '${method}'.`, + data: diagnostics, + method, + operation: "decode-extension-request-payload", + ...diagnostics, + cause, + }); + } + + static internalError( + message = "Internal error", + data?: unknown, + diagnostics: AcpRequestDiagnostics = {}, + ) { return new AcpRequestError({ code: -32603, errorMessage: message, ...(data !== undefined ? { data } : {}), + ...diagnostics, }); } @@ -138,6 +303,7 @@ export const AcpError = Schema.Union([ AcpProcessExitedError, AcpProtocolParseError, AcpTransportError, + AcpInputStreamEndedError, ]); export type AcpError = typeof AcpError.Type; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 093d4acfcfa..c8e03dd7235 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -48,6 +48,8 @@ const decodeExtRequest = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); const decodeRequestPermissionResponse = Schema.decodeEffect( Schema.fromJsonString(RequestPermissionResponse), ); +const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +const encoder = new TextEncoder(); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), ); @@ -132,6 +134,49 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("keeps invalid core notification values only in the schema cause", () => + Effect.gen(function* () { + const secret = "acp-core-notification-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: { secret }, + update: { + sessionUpdate: "plan", + entries: [], + }, + }, + })}\n`, + ), + ); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, AcpError.AcpProtocolParseError); + const parseError = error as AcpError.AcpProtocolParseError; + const { cause, ...directDiagnostics } = parseError; + assert.equal(parseError.operation, "decode-notification-payload"); + assert.equal(parseError.method, "session/update"); + assert.isAbove(parseError.issueCount ?? 0, 0); + assert.include(parseError.issueKinds ?? [], "Pointer"); + assert.isAbove(parseError.maximumPathDepth ?? 0, 0); + assert.isTrue(Schema.isSchemaError(cause)); + assert.notInclude(parseError.message, secret); + assert.notInclude(encodeUnknownJsonString(directDiagnostics), secret); + }), + ); + it.effect("logs outgoing notifications when logOutgoing is enabled", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); @@ -172,6 +217,38 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("logs decode failures without copying the cause or wire payload", () => + Effect.gen(function* () { + const secret = "acp-wire-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const events: Array = []; + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + logIncoming: true, + logger: (event) => + Effect.sync(() => { + events.push(event); + }), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer(input, encoder.encode(`{"secret":"${secret}"\n`)); + yield* Deferred.await(termination); + + const event = events.find(({ stage }) => stage === "decode_failed"); + assert.deepEqual(event, { + direction: "incoming", + stage: "decode_failed", + payload: { + operation: "decode-wire-message", + }, + }); + assert.notInclude(encodeUnknownJsonString(event), secret); + }), + ); + it.effect("fails notification encoding through the declared ACP error channel", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); @@ -182,13 +259,16 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); - assert.equal(bigintError.detail, "Failed to encode ACP message"); + assert.equal(bigintError.operation, "encode-message"); + assert.instanceOf(bigintError.cause, TypeError); + assert.equal(bigintError.message, "ACP protocol operation 'encode-message' failed."); const circular: Record = {}; circular.self = circular; const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, AcpError.AcpProtocolParseError); - assert.equal(circularError.detail, "Failed to encode ACP message"); + assert.equal(circularError.operation, "encode-message"); + assert.instanceOf(circularError.cause, TypeError); }), ); @@ -381,14 +461,35 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; + readonly message: string; readonly cause: unknown; }; assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "ACP protocol terminated."); assert.instanceOf(defect.cause, AcpError.AcpProcessExitedError); assert.equal((defect.cause as AcpError.AcpProcessExitedError).code, 7); }), ); + it.effect("classifies an input stream ending without inventing a cause", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.end(input); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, AcpError.AcpInputStreamEndedError); + assert.equal(error.message, "ACP input stream ended."); + assert.equal("cause" in error, false); + }), + ); + it.effect("does not emit a second process-exit error after a decode failure", () => Effect.gen(function* () { const handle = yield* makeHandle({ @@ -413,9 +514,40 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; + readonly message: string; readonly cause: unknown; }; assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "ACP protocol terminated."); + assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); + }), + ); + + it.effect("keeps client send failure messages independent from the cause", () => + Effect.gen(function* () { + const { stdio } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const failure = yield* transport.clientProtocol + .send(0, { + _tag: "Request", + id: "request-1", + tag: "x/test", + payload: 1n, + headers: [], + }) + .pipe(Effect.flip); + const defect = failure.reason as { + readonly _tag: string; + readonly message: string; + readonly cause: unknown; + }; + + assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "Failed to send ACP protocol message."); assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); }), ); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 56a7ce81ab8..6c3bd399028 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -17,7 +17,6 @@ import * as AcpSchema from "./_generated/schema.gen.ts"; import { CLIENT_METHODS } from "./_generated/meta.gen.ts"; import * as AcpError from "./errors.ts"; const isAcpError = Schema.is(AcpError.AcpError); -const isAcpRequestError = Schema.is(AcpError.AcpRequestError); export interface AcpProtocolLogEvent { readonly direction: "incoming" | "outgoing"; @@ -114,7 +113,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi try: () => parser.encode(message), catch: (cause) => new AcpError.AcpProtocolParseError({ - detail: "Failed to encode ACP message", + operation: "encode-message", cause, }), }); @@ -184,7 +183,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi _tag: "ClientProtocolError", error: new RpcClientError.RpcClientError({ reason: new RpcClientError.RpcClientDefect({ - message: error.message, + message: "ACP protocol terminated.", cause: error, }), }), @@ -243,7 +242,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi } return options.onExtRequest(message.tag, message.payload).pipe( Effect.matchEffect({ - onFailure: (error) => respondWithError(message.id, normalizeToRequestError(error)), + onFailure: (error) => + respondWithError( + message.id, + AcpError.AcpRequestError.fromExtensionHandlerError(error, message.tag), + ), onSuccess: (value) => respondWithSuccess(message.id, value), }), ); @@ -261,12 +264,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi params, }) satisfies AcpIncomingNotification, ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${CLIENT_METHODS.session_update} notification payload`, - cause, - }), + Effect.mapError((cause) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + CLIENT_METHODS.session_update, + cause, + ), ), Effect.flatMap(dispatchNotification), ); @@ -281,12 +284,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi params, }) satisfies AcpIncomingNotification, ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${CLIENT_METHODS.session_elicitation_complete} notification payload`, - cause, - }), + Effect.mapError((cause) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + CLIENT_METHODS.session_elicitation_complete, + cause, + ), ), Effect.flatMap(dispatchNotification), ); @@ -379,7 +382,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi >, catch: (cause) => new AcpError.AcpProtocolParseError({ - detail: "Failed to decode ACP wire message", + operation: "decode-wire-message", cause, }), }), @@ -396,8 +399,13 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi direction: "incoming", stage: "decode_failed", payload: { - detail: error.detail, - cause: error.cause, + operation: error.operation, + ...(error.method === undefined ? {} : { method: error.method }), + ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), + ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), + ...(error.maximumPathDepth === undefined + ? {} + : { maximumPathDepth: error.maximumPathDepth }), }, }), ), @@ -413,7 +421,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const normalized: AcpError.AcpError = isAcpError(error) ? error : new AcpError.AcpTransportError({ - detail: error instanceof Error ? error.message : String(error), + operation: "read-input-stream", cause: error, }); return handleTermination(() => Effect.succeed(normalized)); @@ -421,13 +429,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi onSuccess: () => handleTermination( () => - options.terminationError ?? - Effect.succeed( - new AcpError.AcpTransportError({ - detail: "ACP input stream ended", - cause: new Error("ACP input stream ended"), - }), - ), + options.terminationError ?? Effect.succeed(new AcpError.AcpInputStreamEndedError({})), ), }), Effect.forkScoped, @@ -441,7 +443,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Stream.runForEach((message) => f(message)), Effect.forever, ), - send: (_clientId, request) => offerOutgoing(request).pipe(Effect.mapError(toRpcClientError)), + send: (_clientId, request) => + offerOutgoing(request).pipe( + Effect.mapError( + (error) => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "Failed to send ACP protocol message.", + cause: error, + }), + }), + ), + ), supportsAck: true, supportsTransferables: false, }); @@ -521,16 +534,3 @@ function isProtocolError( typeof value.message === "string" ); } - -function normalizeToRequestError(error: AcpError.AcpError): AcpError.AcpRequestError { - return isAcpRequestError(error) ? error : AcpError.AcpRequestError.internalError(error.message); -} - -function toRpcClientError(error: AcpError.AcpError): RpcClientError.RpcClientError { - return new RpcClientError.RpcClientError({ - reason: new RpcClientError.RpcClientDefect({ - message: error.message, - cause: error, - }), - }); -} diff --git a/packages/effect-acp/src/terminal.ts b/packages/effect-acp/src/terminal.ts index 088ff863738..b892f040436 100644 --- a/packages/effect-acp/src/terminal.ts +++ b/packages/effect-acp/src/terminal.ts @@ -23,23 +23,3 @@ export interface AcpTerminal { */ readonly release: Effect.Effect; } - -export interface MakeTerminalOptions { - readonly sessionId: string; - readonly terminalId: string; - readonly output: Effect.Effect; - readonly waitForExit: Effect.Effect; - readonly kill: Effect.Effect; - readonly release: Effect.Effect; -} - -export function makeTerminal(options: MakeTerminalOptions): AcpTerminal { - return { - sessionId: options.sessionId, - terminalId: options.terminalId, - output: options.output, - waitForExit: options.waitForExit, - kill: options.kill, - release: options.release, - }; -} From 7b791895ad043e180c1d56595a08866e16d0766e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:27:28 -0700 Subject: [PATCH 167/257] [codex] Structure relay auth persistence errors (#3250) Co-authored-by: codex --- infra/relay/src/auth/DpopProofs.test.ts | 26 ++ infra/relay/src/auth/DpopProofs.ts | 41 ++- .../auth/DpopProofs.verifyAndConsume.test.ts | 14 +- .../EnvironmentCredentials.test.ts | 82 ++++++ .../environments/EnvironmentCredentials.ts | 268 +++++++++++------- .../src/environments/EnvironmentLinks.test.ts | 70 +++++ .../src/environments/EnvironmentLinks.ts | 225 ++++++++++----- infra/relay/src/http/Api.test.ts | 1 + infra/relay/src/http/Api.ts | 96 +++++-- 9 files changed, 614 insertions(+), 209 deletions(-) diff --git a/infra/relay/src/auth/DpopProofs.test.ts b/infra/relay/src/auth/DpopProofs.test.ts index b294ba396b6..fba64586e28 100644 --- a/infra/relay/src/auth/DpopProofs.test.ts +++ b/infra/relay/src/auth/DpopProofs.test.ts @@ -96,4 +96,30 @@ describe("DpopProofReplay", () => { Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), ); }); + + it.effect("retains the prune cutoff and database failure", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + delete: (table: unknown) => { + expect(table).toBe(relayDpopProofs); + return { + where: () => Effect.fail(cause), + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const error = yield* Effect.flip(replay.pruneExpired); + + expect(error).toMatchObject({ + _tag: "DpopProofReplayPersistenceError", + operation: "prune-expired", + }); + expect(Date.parse(error.expiresBefore ?? "")).not.toBeNaN(); + expect(error.cause).toBe(cause); + }).pipe( + Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); }); diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts index cf3f7a4cf5a..fa784eb639b 100644 --- a/infra/relay/src/auth/DpopProofs.ts +++ b/infra/relay/src/auth/DpopProofs.ts @@ -13,11 +13,16 @@ import { relayDpopProofs } from "../persistence/schema.ts"; export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass()( "DpopProofReplayPersistenceError", { + operation: Schema.Literals(["consume", "prune-expired"]), + thumbprint: Schema.optionalKey(Schema.String), + jti: Schema.optionalKey(Schema.String), + iat: Schema.optionalKey(Schema.Number), + expiresBefore: Schema.optionalKey(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to persist DPoP proof replay state"; + return `Failed to persist DPoP proof replay state during '${this.operation}'`; } } @@ -58,10 +63,21 @@ const make = Effect.gen(function* () { createdAt, }) .onConflictDoNothing() - .returning({ jti: relayDpopProofs.jti }); + .returning({ jti: relayDpopProofs.jti }) + .pipe( + Effect.mapError( + (cause) => + new DpopProofReplayPersistenceError({ + operation: "consume", + thumbprint: input.thumbprint, + jti: input.jti, + iat: input.iat, + cause, + }), + ), + ); return inserted.length > 0; }, - Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), ); const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn( @@ -114,11 +130,20 @@ const make = Effect.gen(function* () { const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now }); - yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now)); - }).pipe( - Effect.withSpan("relay.dpop_proofs.prune_expired"), - Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), - ); + yield* db + .delete(relayDpopProofs) + .where(lt(relayDpopProofs.expiresAt, now)) + .pipe( + Effect.mapError( + (cause) => + new DpopProofReplayPersistenceError({ + operation: "prune-expired", + expiresBefore: now, + cause, + }), + ), + ); + }).pipe(Effect.withSpan("relay.dpop_proofs.prune_expired")); return DpopProofReplay.of({ verifyAndConsume, diff --git a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts index ecb33f1fc06..7663e874879 100644 --- a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts +++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts @@ -163,7 +163,7 @@ describe("DpopProofReplay.verifyAndConsume", () => { iat: Math.floor(now.epochMilliseconds / 1_000), jti: "proof-persistence-failure", }); - const cause = "database unavailable"; + const cause = { _tag: "DatabaseUnavailable" } as const; return Effect.gen(function* () { const replay = yield* DpopProofs.DpopProofReplay; @@ -177,8 +177,16 @@ describe("DpopProofReplay.verifyAndConsume", () => { }), ); - expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause })); - }).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause })))); + expect(error).toMatchObject({ + _tag: "DpopProofReplayPersistenceError", + operation: "consume", + thumbprint: proof.thumbprint, + jti: "proof-persistence-failure", + iat: Math.floor(now.epochMilliseconds / 1_000), + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("proof"); + }).pipe(Effect.provide(layer(() => Effect.fail(cause)))); }); it.effect("accepts proofs bound to the access token hash", () => { diff --git a/infra/relay/src/environments/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts index 733658cbb5e..4e12dabe831 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.test.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -9,6 +9,88 @@ import { relayEnvironmentCredentials } from "../persistence/schema.ts"; import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; describe("EnvironmentCredentials", () => { + it.effect("reports the credential creation persistence stage and preserves its cause", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + values: () => Effect.void, + }; + }, + update: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + set: () => ({ + where: () => Effect.fail(cause), + }), + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const error = yield* Effect.flip( + credentials.create({ + environmentId: "env_test", + environmentPublicKey: "sensitive-public-key-material", + }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentCredentialCreatePersistenceError", + stage: "revoke-previous-credentials", + environmentId: "env_test", + }); + expect(error.credentialId).toMatch(/^[0-9a-f]{64}$/); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("environmentPublicKey"); + }).pipe( + Effect.provide( + EnvironmentCredentials.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("does not retain credential tokens when lookup persistence fails", () => { + const cause = new Error("database unavailable"); + const token = "t3env_sensitive-credential-token"; + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const error = yield* Effect.flip(credentials.authenticate(token)); + + expect(error).toMatchObject({ + _tag: "EnvironmentCredentialAuthenticatePersistenceError", + stage: "lookup-credential", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("token"); + }).pipe( + Effect.provide( + EnvironmentCredentials.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); + it.effect( "creates opaque credentials and revokes only older credentials for the same key", () => { diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts index e318ce1e098..39f40d941b8 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -13,28 +13,44 @@ import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persisten export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialCreatePersistenceError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals([ + "generate-credential", + "hash-token", + "insert-credential", + "revoke-previous-credentials", + ]), + environmentId: Schema.String, + credentialId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist environment credential"; + return `Environment credential creation failed during '${this.stage}' for environment '${this.environmentId}'${this.credentialId === undefined ? "" : `, credential '${this.credentialId}'`}`; } } export class EnvironmentCredentialAuthenticatePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialAuthenticatePersistenceError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals(["hash-token", "lookup-credential"]), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to authenticate environment credential"; + return `Environment credential authentication failed during '${this.stage}'`; } } export class EnvironmentCredentialRevokePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialRevokePersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to revoke environment credential"; + return `Failed to revoke credentials for environment '${this.environmentId}'`; } } @@ -85,13 +101,33 @@ const make = Effect.gen(function* () { }); return EnvironmentCredentials.of({ - create: Effect.fn("relay.environment_credentials.create")( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); - const credential = yield* makeCredential(); - const credentialHash = yield* hashToken(credential.token); - const now = DateTime.formatIso(yield* DateTime.now); - yield* db.insert(relayEnvironmentCredentials).values({ + create: Effect.fn("relay.environment_credentials.create")(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + const credential = yield* makeCredential().pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "generate-credential", + environmentId: input.environmentId, + cause, + }), + ), + ); + const credentialHash = yield* hashToken(credential.token).pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "hash-token", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + const now = DateTime.formatIso(yield* DateTime.now); + yield* db + .insert(relayEnvironmentCredentials) + .values({ credentialId: credential.credentialId, environmentId: input.environmentId, environmentPublicKey: input.environmentPublicKey, @@ -99,96 +135,136 @@ const make = Effect.gen(function* () { revokedAt: null, createdAt: now, updatedAt: now, - }); - yield* db - .update(relayEnvironmentCredentials) - .set({ - revokedAt: now, - updatedAt: now, - }) - .where( - and( - eq(relayEnvironmentCredentials.environmentId, input.environmentId), - eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), - ne(relayEnvironmentCredentials.credentialId, credential.credentialId), - isNull(relayEnvironmentCredentials.revokedAt), - ), - ); - return credential.token; - }, - Effect.mapError((cause) => new EnvironmentCredentialCreatePersistenceError({ cause })), - ), + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "insert-credential", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + yield* db + .update(relayEnvironmentCredentials) + .set({ + revokedAt: now, + updatedAt: now, + }) + .where( + and( + eq(relayEnvironmentCredentials.environmentId, input.environmentId), + eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), + ne(relayEnvironmentCredentials.credentialId, credential.credentialId), + isNull(relayEnvironmentCredentials.revokedAt), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "revoke-previous-credentials", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + return credential.token; + }), - authenticate: Effect.fn("relay.environment_credentials.authenticate")( - function* (token) { - const credentialHash = yield* hashToken(token); - const rows = yield* db - .select({ - credentialId: relayEnvironmentCredentials.credentialId, - environmentId: relayEnvironmentCredentials.environmentId, - environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey, + authenticate: Effect.fn("relay.environment_credentials.authenticate")(function* (token) { + const credentialHash = yield* hashToken(token).pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialAuthenticatePersistenceError({ + stage: "hash-token", + cause, + }), + ), + ); + const rows = yield* db + .select({ + credentialId: relayEnvironmentCredentials.credentialId, + environmentId: relayEnvironmentCredentials.environmentId, + environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey, + }) + .from(relayEnvironmentCredentials) + .where( + and( + eq(relayEnvironmentCredentials.credentialHash, credentialHash), + isNull(relayEnvironmentCredentials.revokedAt), + ), + ) + .limit(1) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialAuthenticatePersistenceError({ + stage: "lookup-credential", + cause, + }), + ), + ); + const row = rows[0]; + if (row) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId }); + } + return row + ? Option.some({ + credentialId: row.credentialId, + environmentId: row.environmentId, + environmentPublicKey: row.environmentPublicKey, }) - .from(relayEnvironmentCredentials) - .where( - and( - eq(relayEnvironmentCredentials.credentialHash, credentialHash), - isNull(relayEnvironmentCredentials.revokedAt), - ), - ) - .limit(1); - const row = rows[0]; - if (row) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId }); - } - return row - ? Option.some({ - credentialId: row.credentialId, - environmentId: row.environmentId, - environmentPublicKey: row.environmentPublicKey, - }) - : Option.none(); - }, - Effect.mapError((cause) => new EnvironmentCredentialAuthenticatePersistenceError({ cause })), - ), + : Option.none(); + }), revokeForEnvironmentPublicKey: Effect.fn( "relay.environment_credentials.revoke_for_environment_public_key", - )( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); - const revokedAt = DateTime.formatIso(yield* DateTime.now); - const rows = yield* db - .update(relayEnvironmentCredentials) - .set({ - revokedAt, - updatedAt: revokedAt, - }) - .where( - and( - eq(relayEnvironmentCredentials.environmentId, input.environmentId), - eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), - isNull(relayEnvironmentCredentials.revokedAt), - notExists( - db - .select({ userId: relayEnvironmentLinks.userId }) - .from(relayEnvironmentLinks) - .where( - and( - eq(relayEnvironmentLinks.environmentId, input.environmentId), - eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), - isNull(relayEnvironmentLinks.revokedAt), - ), + )(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + const revokedAt = DateTime.formatIso(yield* DateTime.now); + const rows = yield* db + .update(relayEnvironmentCredentials) + .set({ + revokedAt, + updatedAt: revokedAt, + }) + .where( + and( + eq(relayEnvironmentCredentials.environmentId, input.environmentId), + eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentCredentials.revokedAt), + notExists( + db + .select({ userId: relayEnvironmentLinks.userId }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.environmentId, input.environmentId), + eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentLinks.revokedAt), ), - ), + ), ), - ) - .returning({ - credentialId: relayEnvironmentCredentials.credentialId, - }); - return rows.length > 0; - }, - Effect.mapError((cause) => new EnvironmentCredentialRevokePersistenceError({ cause })), - ), + ), + ) + .returning({ + credentialId: relayEnvironmentCredentials.credentialId, + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialRevokePersistenceError({ + environmentId: input.environmentId, + cause, + }), + ), + ); + return rows.length > 0; + }), }); }); diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index 346daef44a6..dccb9e39f60 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -8,6 +8,76 @@ import { relayEnvironmentLinks } from "../persistence/schema.ts"; import * as EnvironmentLinks from "./EnvironmentLinks.ts"; describe("EnvironmentLinks", () => { + it.effect("retains link lookup failures with user and environment identity", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const error = yield* Effect.flip( + links.getForUser({ userId: "user-1", environmentId: "env-1" }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentLinkLookupPersistenceError", + userId: "user-1", + environmentId: "env-1", + }); + expect(error.cause).toBe(cause); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + + it.effect("identifies delivery-user list failures without retaining key material", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + where: () => Effect.fail(cause), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const error = yield* Effect.flip( + links.listDeliveryUsersForEnvironment({ + environmentId: "env-1", + environmentPublicKey: "sensitive-public-key-material", + }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentLinkUserListPersistenceError", + operation: "list-delivery-users", + environmentId: "env-1", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("environmentPublicKey"); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + it.effect("selects users when either notifications or Live Activities are enabled", () => { const whereConditions: Array = []; const fakeDb = { diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index ee7019656cc..6630af0a11b 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -26,55 +26,78 @@ export interface AgentAwarenessDeliveryUserRecord { export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkUpsertPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + deviceId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist environment link"; + return `Failed to persist environment link for user '${this.userId}', environment '${this.environmentId}'`; } } export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkUserListPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["list-users", "list-delivery-users"]), + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list users linked to environment"; + return `Environment link user query '${this.operation}' failed for environment '${this.environmentId}'`; } } export class EnvironmentPublicKeyListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentPublicKeyListPersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list environment public keys"; + return `Failed to list public keys for environment '${this.environmentId}'`; } } export class EnvironmentLinkListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list environment links"; + return `Failed to list environment links for user '${this.userId}'`; } } export class EnvironmentLinkLookupPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkLookupPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to look up environment link"; + return `Failed to look up environment link for user '${this.userId}', environment '${this.environmentId}'`; } } export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkRevokePersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to revoke environment link"; + return `Failed to revoke environment link for user '${this.userId}', environment '${this.environmentId}'`; } } @@ -142,22 +165,37 @@ const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return EnvironmentLinks.of({ - upsert: Effect.fn("relay.environment_links.upsert")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.proof.environmentId, - }); - const now = DateTime.formatIso(yield* DateTime.now); - const { request, proof } = input; - const environmentId = proof.environmentId; - const { endpoint } = input; - yield* db - .insert(relayEnvironmentLinks) - .values({ - userId: input.userId, - environmentId, - environmentLabel: proof.descriptor.label, + upsert: Effect.fn("relay.environment_links.upsert")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.proof.environmentId, + }); + const now = DateTime.formatIso(yield* DateTime.now); + const { request, proof } = input; + const environmentId = proof.environmentId; + const { endpoint } = input; + yield* db + .insert(relayEnvironmentLinks) + .values({ + userId: input.userId, + environmentId, + environmentLabel: proof.descriptor.label, + environmentPublicKey: proof.environmentPublicKey, + endpointHttpBaseUrl: endpoint.httpBaseUrl, + endpointWsBaseUrl: endpoint.wsBaseUrl, + endpointProviderKind: endpoint.providerKind, + notificationsEnabled: request.notificationsEnabled, + liveActivitiesEnabled: request.liveActivitiesEnabled, + managedTunnelsEnabled: request.managedTunnelsEnabled, + createdByDeviceId: request.deviceId ?? null, + revokedAt: null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], + set: { environmentPublicKey: proof.environmentPublicKey, + environmentLabel: proof.descriptor.label, endpointHttpBaseUrl: endpoint.httpBaseUrl, endpointWsBaseUrl: endpoint.wsBaseUrl, endpointProviderKind: endpoint.providerKind, @@ -166,28 +204,21 @@ const make = Effect.gen(function* () { managedTunnelsEnabled: request.managedTunnelsEnabled, createdByDeviceId: request.deviceId ?? null, revokedAt: null, - createdAt: now, updatedAt: now, - }) - .onConflictDoUpdate({ - target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], - set: { - environmentPublicKey: proof.environmentPublicKey, - environmentLabel: proof.descriptor.label, - endpointHttpBaseUrl: endpoint.httpBaseUrl, - endpointWsBaseUrl: endpoint.wsBaseUrl, - endpointProviderKind: endpoint.providerKind, - notificationsEnabled: request.notificationsEnabled, - liveActivitiesEnabled: request.liveActivitiesEnabled, - managedTunnelsEnabled: request.managedTunnelsEnabled, - createdByDeviceId: request.deviceId ?? null, - revokedAt: null, - updatedAt: now, - }, - }); - }, - Effect.mapError((cause) => new EnvironmentLinkUpsertPersistenceError({ cause })), - ), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkUpsertPersistenceError({ + userId: input.userId, + environmentId, + ...(request.deviceId === undefined ? {} : { deviceId: request.deviceId }), + cause, + }), + ), + ); + }), listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")( function* (input) { @@ -198,7 +229,14 @@ const make = Effect.gen(function* () { .where(agentAwarenessDeliveryUserCondition(input.environmentId)) .pipe( Effect.map((rows) => rows.map((row) => row.userId)), - Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkUserListPersistenceError({ + operation: "list-users", + environmentId: input.environmentId, + cause, + }), + ), ); }, ), @@ -223,7 +261,14 @@ const make = Effect.gen(function* () { liveActivitiesEnabled: row.liveActivitiesEnabled, })), ), - Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkUserListPersistenceError({ + operation: "list-delivery-users", + environmentId: input.environmentId, + cause, + }), + ), ); }), @@ -244,7 +289,13 @@ const make = Effect.gen(function* () { Effect.map((rows) => [ ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)), ]), - Effect.mapError((cause) => new EnvironmentPublicKeyListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentPublicKeyListPersistenceError({ + environmentId: input.environmentId, + cause, + }), + ), ); }), @@ -280,7 +331,13 @@ const make = Effect.gen(function* () { linkedAt: row.createdAt, })), ), - Effect.mapError((cause) => new EnvironmentLinkListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkListPersistenceError({ + userId: input.userId, + cause, + }), + ), ); }), @@ -328,34 +385,48 @@ const make = Effect.gen(function* () { } : null; }), - Effect.mapError((cause) => new EnvironmentLinkLookupPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkLookupPersistenceError({ + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), ); }), - revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.environmentId, - }); - const revokedAt = DateTime.formatIso(yield* DateTime.now); - const rows = yield* db - .update(relayEnvironmentLinks) - .set({ - revokedAt, - updatedAt: revokedAt, - }) - .where( - and( - eq(relayEnvironmentLinks.userId, input.userId), - eq(relayEnvironmentLinks.environmentId, input.environmentId), - isNull(relayEnvironmentLinks.revokedAt), - ), - ) - .returning({ environmentId: relayEnvironmentLinks.environmentId }); - return rows.length > 0; - }, - Effect.mapError((cause) => new EnvironmentLinkRevokePersistenceError({ cause })), - ), + revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + }); + const revokedAt = DateTime.formatIso(yield* DateTime.now); + const rows = yield* db + .update(relayEnvironmentLinks) + .set({ + revokedAt, + updatedAt: revokedAt, + }) + .where( + and( + eq(relayEnvironmentLinks.userId, input.userId), + eq(relayEnvironmentLinks.environmentId, input.environmentId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .returning({ environmentId: relayEnvironmentLinks.environmentId }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkRevokePersistenceError({ + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), + ); + return rows.length > 0; + }), }); }); diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 6061c6e8174..158bcec9803 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -108,6 +108,7 @@ describe("relay client authentication", () => { describe("relay environment authentication", () => { it.effect("preserves credential lookup persistence failures as internal errors", () => { const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ + stage: "lookup-credential", cause: "database unavailable", }); const credentials: EnvironmentCredentials.EnvironmentCredentials["Service"] = { diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 33bcd187c26..29e2026de3c 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -238,13 +238,12 @@ export const relayEnvironmentAuthLayer = Layer.effect( { credential }, ) { const token = readHttpAuthorizationCredential(credential); - const principal = yield* credentials - .authenticate(token) - .pipe( - Effect.catchTag("EnvironmentCredentialAuthenticatePersistenceError", () => + const principal = yield* credentials.authenticate(token).pipe( + Effect.catchTags({ + EnvironmentCredentialAuthenticatePersistenceError: () => relayInternalErrorResponse("persistence_failed"), - ), - ); + }), + ); if (principal._tag === "None") { return yield* relayAuthInvalidError("not_authorized"); } @@ -777,17 +776,72 @@ export const serverApi = HttpApiBuilder.group( reason: "persistence_failed", traceId, }), - ApnsDeliveryJobQueuePayloadInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobLiveActivityAggregateMissing: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobLiveActivityNotificationUnexpected: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobPushNotificationMissing: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobPushNotificationAggregateUnexpected: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobCreatedAtInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobExpiresAtInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobTimeWindowInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobTimeWindowTooLong: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobSignatureInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobExpired: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobQueuePayloadInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobLiveActivityAggregateMissing: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobLiveActivityNotificationUnexpected: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobPushNotificationMissing: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobPushNotificationAggregateUnexpected: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobCreatedAtInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobExpiresAtInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobTimeWindowInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobTimeWindowTooLong: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobSignatureInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobExpired: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), ApnsDeliveryJobClaimInFlight: (_error, traceId) => new RelayInternalError({ code: "internal_error", @@ -892,14 +946,6 @@ 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 c1d8a22f073e36768a8af5b0ef6472d802cbfa4d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:28:14 -0700 Subject: [PATCH 168/257] [codex] Structure desktop persisted credential errors (#3239) Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 16 +- .../src/app/DesktopConnectionCatalogStore.ts | 195 +++++++++++++----- .../settings/DesktopSavedEnvironments.test.ts | 21 +- .../src/settings/DesktopSavedEnvironments.ts | 152 +++++++++++--- 4 files changed, 291 insertions(+), 93 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index e0be7f39b39..7c7818994f6 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -393,7 +393,21 @@ describe("DesktopConnectionCatalogStore", () => { 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); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, + ); + assert.equal(error.operation, "decrypt-catalog"); + assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError); + const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError; + assert.instanceOf(decryptError.cause, Error); + assert.equal(decryptError.cause.message, "invalid encrypted catalog"); + assert.equal( + error.message, + `Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`, + ); + assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 8467fe3f077..5ec2edb595f 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -53,8 +53,6 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "write-temporary-file", "replace-catalog-file", ]); -type DesktopConnectionCatalogStoreWriteOperation = - typeof DesktopConnectionCatalogStoreWriteOperation.Type; const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ "read-legacy-registry", @@ -62,8 +60,12 @@ const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ "encode-catalog", "persist-catalog", ]); -type DesktopConnectionCatalogStoreMigrationOperation = - typeof DesktopConnectionCatalogStoreMigrationOperation.Type; + +const DesktopConnectionCatalogStoreProtectionOperation = Schema.Literals([ + "check-encryption-availability", + "encrypt-catalog", + "decrypt-catalog", +]); export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreWriteError", @@ -78,17 +80,6 @@ export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorC } } -const writeError = ( - operation: DesktopConnectionCatalogStoreWriteOperation, - path: string, - cause: unknown, -): DesktopConnectionCatalogStoreWriteError => - new DesktopConnectionCatalogStoreWriteError({ - operation, - path, - cause, - }); - export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", { @@ -142,18 +133,18 @@ export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedEr } } -const migrationError = ( - operation: DesktopConnectionCatalogStoreMigrationOperation, - catalogPath: string, - cause: unknown, - environmentId?: string, -): DesktopConnectionCatalogStoreMigrationError => - new DesktopConnectionCatalogStoreMigrationError({ - operation, - catalogPath, - ...(environmentId === undefined ? {} : { environmentId }), - cause, - }); +export class DesktopConnectionCatalogStoreProtectionError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreProtectionError", + { + operation: DesktopConnectionCatalogStoreProtectionOperation, + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop connection catalog protection failed during ${this.operation} at ${this.catalogPath}.`; + } +} export class DesktopConnectionCatalogStore extends Context.Service< DesktopConnectionCatalogStore, @@ -164,13 +155,13 @@ export class DesktopConnectionCatalogStore extends Context.Service< | DesktopConnectionCatalogStoreDocumentDecodeError | DesktopConnectionCatalogStoreDecodeError | DesktopConnectionCatalogStoreMigrationError - | ElectronSafeStorage.ElectronSafeStorageError + | DesktopConnectionCatalogStoreProtectionError >; readonly set: ( catalog: string, ) => Effect.Effect< boolean, - DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError + DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError >; readonly clear: Effect.Effect; } @@ -236,20 +227,46 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")( const directory = input.path.dirname(input.catalogPath); const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe( - Effect.mapError((cause) => writeError("encode-document", input.catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "encode-document", + path: input.catalogPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), ); - yield* input.fileSystem - .makeDirectory(directory, { recursive: true }) - .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); yield* Effect.gen(function* () { - yield* input.fileSystem - .writeFileString(tempPath, `${encoded}\n`) - .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); - yield* input.fileSystem - .rename(tempPath, input.catalogPath) - .pipe( - Effect.mapError((cause) => writeError("replace-catalog-file", input.catalogPath, cause)), - ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.catalogPath).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "replace-catalog-file", + path: input.catalogPath, + cause, + }), + ), + ); }).pipe( Effect.ensuring( input.fileSystem.remove(tempPath, { force: true }).pipe( @@ -330,13 +347,17 @@ const migrateSavedEnvironmentRecords = Effect.fn( wsBaseUrl: record.wsBaseUrl, }), ); - const token = yield* savedEnvironments - .getSecret(record.environmentId) - .pipe( - Effect.mapError((cause) => - migrationError("read-legacy-secret", catalogPath, cause, record.environmentId), - ), - ); + const token = yield* savedEnvironments.getSecret(record.environmentId).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "read-legacy-secret", + catalogPath, + environmentId: record.environmentId, + cause, + }), + ), + ); if (Option.isSome(token)) { credentials.push({ connectionId: id, @@ -362,13 +383,41 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); + const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "check-encryption-availability", + catalogPath, + cause, + }), + ), + ); const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( catalog: string, ) { - const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); + const encryptedCatalog = Encoding.encodeBase64( + yield* safeStorage.encryptString(catalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "encrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ); const suffix = (yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "create-temporary-file-name", + path: catalogPath, + cause, + }), + ), )).replace(/-/g, ""); yield* writeDocument({ fileSystem, @@ -380,21 +429,42 @@ export const make = Effect.gen(function* () { }); const migrateLegacyCatalog = Effect.gen(function* () { - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return Option.none(); } const records = yield* savedEnvironments.getRegistry.pipe( - Effect.mapError((cause) => migrationError("read-legacy-registry", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "read-legacy-registry", + catalogPath, + cause, + }), + ), ); if (records.length === 0) { return Option.none(); } const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath); const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe( - Effect.mapError((cause) => migrationError("encode-catalog", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "encode-catalog", + catalogPath, + cause, + }), + ), ); yield* writeCatalog(encoded).pipe( - Effect.mapError((cause) => migrationError("persist-catalog", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "persist-catalog", + catalogPath, + cause, + }), + ), ); return Option.some(encoded); }); @@ -405,16 +475,27 @@ export const make = Effect.gen(function* () { if (Option.isNone(document)) { return yield* migrateLegacyCatalog; } - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return Option.none(); } const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( - Effect.flatMap(safeStorage.decryptString), + Effect.flatMap((encryptedCatalog) => + safeStorage.decryptString(encryptedCatalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "decrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ), ); return Option.some(decrypted); }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return false; } yield* writeCatalog(catalog); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index abd25a39f5b..ec70308b3d3 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -271,10 +271,11 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("surfaces typed safe storage availability failures", () => { + it.effect("adds saved-environment context to safe storage availability failures", () => { const cause = new Error("safe storage unavailable"); return withSavedEnvironments( Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; yield* savedEnvironments.setRegistry([savedRegistryRecord]); @@ -285,8 +286,22 @@ describe("DesktopSavedEnvironments", () => { }) .pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); - assert.equal(error.cause, cause); + assert.instanceOf( + error, + DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError, + ); + assert.equal(error.operation, "check-encryption-availability"); + assert.equal(error.environmentId, savedRegistryRecord.environmentId); + assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); + assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); + const availabilityError = + error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError; + assert.strictEqual(availabilityError.cause, cause); + assert.equal( + error.message, + `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, + ); + assert.notEqual(error.message, availabilityError.message); }), { availabilityError: cause }, ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 64c40d39f0e..bdda7f9c738 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -77,7 +77,12 @@ const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([ "write-temporary-file", "replace-registry-file", ]); -type DesktopSavedEnvironmentsWriteOperation = typeof DesktopSavedEnvironmentsWriteOperation.Type; + +const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([ + "check-encryption-availability", + "encrypt-secret", + "decrypt-secret", +]); export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsWriteError", @@ -92,17 +97,6 @@ export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass< } } -const writeError = ( - operation: DesktopSavedEnvironmentsWriteOperation, - path: string, - cause: unknown, -): DesktopSavedEnvironmentsWriteError => - new DesktopSavedEnvironmentsWriteError({ - operation, - path, - cause, - }); - export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsReadError", { @@ -141,6 +135,20 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedError } } +export class DesktopSavedEnvironmentSecretProtectionError extends Schema.TaggedErrorClass()( + "DesktopSavedEnvironmentSecretProtectionError", + { + operation: DesktopSavedEnvironmentSecretProtectionOperation, + environmentId: Schema.String, + registryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop saved-environment secret protection failed during ${this.operation} for environment ${this.environmentId} at ${this.registryPath}.`; + } +} + export type DesktopSavedEnvironmentsReadRegistryError = | DesktopSavedEnvironmentsReadError | DesktopSavedEnvironmentsDocumentDecodeError; @@ -152,11 +160,11 @@ export type DesktopSavedEnvironmentsMutationError = export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError - | ElectronSafeStorage.ElectronSafeStorageError; + | DesktopSavedEnvironmentSecretProtectionError; export type DesktopSavedEnvironmentsSetSecretError = | DesktopSavedEnvironmentsMutationError - | ElectronSafeStorage.ElectronSafeStorageError; + | DesktopSavedEnvironmentSecretProtectionError; export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, @@ -276,19 +284,45 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry const directory = input.path.dirname(input.registryPath); const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe( - Effect.mapError((cause) => writeError("encode-registry", input.registryPath, cause)), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "encode-registry", + path: input.registryPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.registryPath).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "replace-registry-file", + path: input.registryPath, + cause, + }), + ), ); - 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)), - ); }, ); @@ -341,8 +375,13 @@ export const make = Effect.gen(function* () { const writeDocument = (document: SavedEnvironmentRegistryDocument) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError((cause) => - writeError("create-temporary-file-name", environment.savedEnvironmentRegistryPath, cause), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "create-temporary-file-name", + path: environment.savedEnvironmentRegistryPath, + cause, + }), ), Effect.flatMap((suffix) => writeRegistryDocument({ @@ -396,7 +435,21 @@ export const make = Effect.gen(function* () { document.records.find((record) => record.environmentId === environmentId) ?.encryptedBearerToken, ); - if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { + if (Option.isNone(encoded)) { + return Option.none(); + } + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "check-encryption-availability", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ); + if (!encryptionAvailable) { return Option.none(); } @@ -405,7 +458,19 @@ export const make = Effect.gen(function* () { environment.savedEnvironmentRegistryPath, encoded.value, ); - return Option.some(yield* safeStorage.decryptString(secretBytes)); + return Option.some( + yield* safeStorage.decryptString(secretBytes).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "decrypt-secret", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ), + ); }), setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { const { environmentId, secret } = input; @@ -415,11 +480,34 @@ export const make = Effect.gen(function* () { environment.savedEnvironmentRegistryPath, ); - if (!(yield* safeStorage.isEncryptionAvailable)) { + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "check-encryption-availability", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ); + if (!encryptionAvailable) { return false; } - const encryptedBearerToken = Encoding.encodeBase64(yield* safeStorage.encryptString(secret)); + const encryptedBearerToken = Encoding.encodeBase64( + yield* safeStorage.encryptString(secret).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "encrypt-secret", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ), + ); let found = false; const nextDocument: SavedEnvironmentRegistryDocument = { version: document.version, From bd8e3ee008e4c78085ebfbf47bf216389a92ac75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:29:24 -0700 Subject: [PATCH 169/257] Align text generation error catches (#3292) Co-authored-by: codex --- .../textGeneration/ClaudeTextGeneration.ts | 38 ++++++----- .../src/textGeneration/CodexTextGeneration.ts | 19 +++--- .../textGeneration/CursorTextGeneration.ts | 67 ++++++++----------- .../src/textGeneration/GrokTextGeneration.ts | 62 +++++++---------- .../textGeneration/OpenCodeTextGeneration.ts | 19 +++--- 5 files changed, 91 insertions(+), 114 deletions(-) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 872bf936cb1..453bb62b728 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -234,28 +234,30 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); const envelope = yield* decodeClaudeOutputEnvelope(rawStdout).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Claude CLI returned unexpected output format.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude CLI returned unexpected output format.", + cause, + }), + ), + }), ); const decodeOutput = Schema.decodeEffect(outputSchemaJson); return yield* decodeOutput(envelope.structured_output).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Claude returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude returned invalid structured output.", + cause, + }), + ), + }), ); }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 95783b06cca..0e68994fd3d 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -281,15 +281,16 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }), ), Effect.flatMap(decodeOutput), - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Codex returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Codex returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe(Effect.ensuring(cleanup)); }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 24676789b05..3e1f4eb8bbc 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -28,30 +28,7 @@ import { const CURSOR_TIMEOUT_MS = 180_000; -function mapCursorAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} +const isTextGenerationError = Schema.is(TextGenerationError); /** * Build a Cursor text-generation closure bound to a specific `CursorSettings` @@ -111,13 +88,14 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu model: modelSelection.model, selections: modelSelection.options, mapError: ({ cause, configId, step }) => - mapCursorAcpError( + new TextGenerationError({ operation, - step === "set-config-option" - ? `Failed to set Cursor ACP config option "${configId}" for text generation.` - : "Failed to set Cursor ACP base model for text generation.", + detail: + step === "set-config-option" + ? `Failed to set Cursor ACP config option "${configId}" for text generation.` + : "Failed to set Cursor ACP base model for text generation.", cause, - ), + }), }); return yield* runtime.prompt({ @@ -140,7 +118,11 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), + : new TextGenerationError({ + operation, + detail: "Cursor ACP request failed.", + cause, + }), ), ); @@ -157,21 +139,26 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawResult)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe( Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), + : new TextGenerationError({ + operation, + detail: "Cursor ACP text generation failed.", + cause, + }), ), Effect.scoped, ); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index ab52efb1116..1bb58216305 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -31,30 +31,7 @@ import { const GROK_TIMEOUT_MS = 180_000; -function mapGrokAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} +const isTextGenerationError = Schema.is(TextGenerationError); export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* ( grokSettings: GrokSettings, @@ -109,11 +86,11 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), requestedModelId: resolvedModel, mapError: (cause) => - mapGrokAcpError( + new TextGenerationError({ operation, - "Failed to set Grok ACP base model for text generation.", + detail: "Failed to set Grok ACP base model for text generation.", cause, - ), + }), }); return yield* runtime.prompt({ @@ -133,7 +110,11 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => isTextGenerationError(cause) ? cause - : mapGrokAcpError(operation, "Grok ACP request failed.", cause), + : new TextGenerationError({ + operation, + detail: "Grok ACP request failed.", + cause, + }), ), ); @@ -150,21 +131,26 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); return yield* decodeOutput(extractJsonObject(trimmed)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Grok Agent returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Grok Agent returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe( Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapGrokAcpError(operation, "Grok ACP text generation failed.", cause), + : new TextGenerationError({ + operation, + detail: "Grok ACP text generation failed.", + cause, + }), ), Effect.scoped, ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 0ba7726d68c..f59e7694213 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -348,15 +348,16 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + }), ); }); From d7ff7e73336482d64449f72a4bd31d9ce9f4c01e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:30:03 -0700 Subject: [PATCH 170/257] [codex] Audit managed endpoint error context (#3245) Co-authored-by: codex --- .../ManagedEndpointAllocations.test.ts | 93 ++++ .../ManagedEndpointAllocations.ts | 133 ++++- .../ManagedEndpointProvider.test.ts | 88 +++- .../environments/ManagedEndpointProvider.ts | 468 +++++++++++++++--- 4 files changed, 678 insertions(+), 104 deletions(-) create mode 100644 infra/relay/src/environments/ManagedEndpointAllocations.test.ts diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts new file mode 100644 index 00000000000..1a3c01d1e13 --- /dev/null +++ b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as RelayDb from "../db.ts"; +import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; +import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; + +const layerWithDb = (db: RelayDb.RelayDb["Service"]) => + ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))); + +describe("ManagedEndpointAllocations", () => { + it.effect("retains database failures with allocation operation and identity", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const error = yield* Effect.flip( + allocations.get({ userId: "user-1", environmentId: "environment-1" }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointAllocationPersistenceError", + operation: "get", + stage: "database-request", + userId: "user-1", + environmentId: "environment-1", + }); + expect(error.cause).toBe(cause); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); + + it.effect("reports an unresolved reservation without manufacturing a cause", () => { + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Effect.succeed([]), + }), + }), + }; + }, + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + where: () => ({ + limit: () => Effect.succeed([]), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const error = yield* Effect.flip( + allocations.reserve({ + userId: "user-1", + environmentId: "environment-1", + hostname: "environment-1.example.test", + tunnelName: "environment-1-tunnel", + }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointAllocationPersistenceError", + operation: "reserve", + stage: "resolve-reservation", + userId: "user-1", + environmentId: "environment-1", + hostname: "environment-1.example.test", + tunnelName: "environment-1-tunnel", + }); + expect(error.cause).toBeUndefined(); + expect(error.message).toContain("'resolve-reservation'"); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); +}); diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index 440f1d48dc3..c951ee03c8d 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -38,15 +38,29 @@ export function resolveReadyManagedEndpoint(input: { export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErrorClass()( "ManagedEndpointAllocationPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals([ + "get", + "reserve", + "record-tunnel", + "record-dns", + "mark-ready", + "remove", + ]), + stage: Schema.Literals(["database-request", "resolve-reservation"]), + userId: Schema.String, + environmentId: Schema.String, + hostname: Schema.optionalKey(Schema.String), + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, ) { override get message(): string { - return "Failed to persist managed endpoint allocation"; + return `Managed endpoint allocation '${this.operation}' failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } -const isManagedEndpointAllocationPersistenceError = Schema.is( - ManagedEndpointAllocationPersistenceError, -); interface ManagedEndpointAllocationKey { readonly userId: string; @@ -106,11 +120,6 @@ const whereAllocation = (input: ManagedEndpointAllocationKey) => eq(relayManagedEndpointAllocations.environmentId, input.environmentId), ); -const persistenceError = (cause: unknown) => - isManagedEndpointAllocationPersistenceError(cause) - ? cause - : new ManagedEndpointAllocationPersistenceError({ cause }); - const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; @@ -125,7 +134,15 @@ const make = Effect.gen(function* () { .limit(1) .pipe( Effect.map((rows) => rows[0] ?? null), - Effect.mapError(persistenceError), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "get", + stage: "database-request", + ...input, + cause, + }), + ), ); }), reserve: Effect.fn("relay.managed_endpoint_allocations.reserve")(function* ( @@ -140,7 +157,18 @@ const make = Effect.gen(function* () { updatedAt: now, }) .onConflictDoNothing() - .returning(allocationSelection); + .returning(allocationSelection) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "reserve", + stage: "database-request", + ...input, + cause, + }), + ), + ); const allocation = inserted[0] ?? @@ -149,16 +177,29 @@ const make = Effect.gen(function* () { .from(relayManagedEndpointAllocations) .where(whereAllocation(input)) .limit(1) - .pipe(Effect.map((rows) => rows[0]))); + .pipe( + Effect.map((rows) => rows[0]), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "reserve", + stage: "database-request", + ...input, + cause, + }), + ), + )); if (allocation === undefined) { return yield* new ManagedEndpointAllocationPersistenceError({ - cause: new Error("Managed endpoint allocation was not persisted."), + operation: "reserve", + stage: "resolve-reservation", + ...input, }); } return allocation; - }, Effect.mapError(persistenceError)), + }), recordTunnel: Effect.fn("relay.managed_endpoint_allocations.record_tunnel")(function* ( input: RecordManagedEndpointTunnelInput, ) { @@ -168,8 +209,19 @@ const make = Effect.gen(function* () { tunnelId: input.tunnelId, updatedAt: DateTime.formatIso(yield* DateTime.now), }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "record-tunnel", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), recordDns: Effect.fn("relay.managed_endpoint_allocations.record_dns")(function* ( input: RecordManagedEndpointDnsInput, ) { @@ -179,8 +231,19 @@ const make = Effect.gen(function* () { dnsRecordId: input.dnsRecordId, updatedAt: DateTime.formatIso(yield* DateTime.now), }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "record-dns", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), markReady: Effect.fn("relay.managed_endpoint_allocations.mark_ready")(function* ( input: ManagedEndpointAllocationKey, ) { @@ -191,13 +254,37 @@ const make = Effect.gen(function* () { readyAt: now, updatedAt: now, }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "mark-ready", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( input: ManagedEndpointAllocationKey, ) { - yield* db.delete(relayManagedEndpointAllocations).where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + yield* db + .delete(relayManagedEndpointAllocations) + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "remove", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), }); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 7b8f0cc2867..479be412380 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -130,6 +130,9 @@ function makeDnsClient( calls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); if (!currentRecords.some((record) => record.id === dnsRecordId)) { return yield* new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "update-record", + hostname: request.name, + dnsRecordId, cause: `DNS record ${dnsRecordId} does not exist.`, }); } @@ -398,7 +401,13 @@ describe("ManagedEndpointProvider", () => { expect(dnsCalls).toHaveLength(0); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { - expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); + expect(result.failure).toMatchObject({ + _tag: "ManagedEndpointOriginNotAllowed", + userId: "user_ABC", + environmentId: "env_ABC", + host: "192.168.1.10", + port: 3773, + }); } }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); }); @@ -593,6 +602,8 @@ describe("ManagedEndpointProvider", () => { const tunnelCalls: TunnelCall[] = []; let deleteAttempts = 0; const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId: "tunnel-id", cause: "Cloudflare tunnel deletion failed", }); const tunnels = makePersistentTunnelClient(tunnelCalls); @@ -618,6 +629,16 @@ describe("ManagedEndpointProvider", () => { }); const first = yield* Effect.result(provider.deprovision(key)); expect(first._tag).toBe("Failure"); + if (first._tag === "Failure") { + expect(first.failure).toMatchObject({ + _tag: "ManagedEndpointDeprovisioningFailed", + stage: "delete-tunnel", + userId: key.userId, + environmentId: key.environmentId, + tunnelId: "tunnel-id", + }); + expect(first.failure.cause).toBe(failure); + } yield* provider.deprovision(key); expect(allocationCalls.map((call) => call.operation)).toEqual([ @@ -639,13 +660,23 @@ describe("ManagedEndpointProvider", () => { ...makeTunnelClient(), delete: () => Effect.fail( - new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause: notFound }), + new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId: "tunnel-id", + cause: notFound, + }), ), }); const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ ...makeDnsClient(), deleteRecord: () => - Effect.fail(new ManagedEndpointProvider.ManagedEndpointDnsClientError({ cause: notFound })), + Effect.fail( + new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "delete-record", + dnsRecordId: "created-record-id", + cause: notFound, + }), + ), }); const layer = providerLayer(tunnelClient, dnsClient, makeAllocations(allocationCalls)); @@ -690,6 +721,8 @@ describe("ManagedEndpointProvider", () => { it.effect("recovers when DNS creation reports failure after the record became visible", () => { const dnsCalls: DnsCall[] = []; const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "create-record", + hostname: expectedManagedHostname("env_ABC"), cause: "ambiguous Cloudflare DNS response", }); let records: ReadonlyArray<{ readonly id: string }> = []; @@ -732,8 +765,43 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); }); + it.effect("reports malformed tunnel responses without manufacturing a cause", () => { + const dnsCalls: DnsCall[] = []; + const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + ...makeTunnelClient(), + create: () => Effect.succeed({ id: "returned-tunnel-id", name: null }), + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const error = yield* Effect.flip( + provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "validate-tunnel-response", + userId: "user_ABC", + environmentId: "env_ABC", + hostname: expectedManagedHostname("env_ABC"), + tunnelName: expectedManagedTunnelName("env_ABC"), + returnedTunnelId: "returned-tunnel-id", + }); + if (error._tag === "ManagedEndpointProvisioningFailed") { + expect(error.cause).toBeUndefined(); + } + expect(dnsCalls).toHaveLength(0); + }).pipe(Effect.provide(providerLayer(tunnelClient, makeDnsClient(dnsCalls)))); + }); + it.effect("fails provisioning when the DNS client fails", () => { const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "list-records", + hostname: expectedManagedHostname("env_ABC"), cause: "Cloudflare DNS failure", }); const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ @@ -753,8 +821,18 @@ describe("ManagedEndpointProvider", () => { }), ); - expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); - expect(error.cause).toBe(failure); + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "ensure-dns-record", + userId: "user_ABC", + environmentId: "env_ABC", + hostname: expectedManagedHostname("env_ABC"), + tunnelName: expectedManagedTunnelName("env_ABC"), + tunnelId: "tunnel-id", + }); + if (error._tag === "ManagedEndpointProvisioningFailed") { + expect(error.cause).toBe(failure); + } }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); }); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 2de9d1966ac..bb2dd4b0ce9 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -7,7 +7,6 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { @@ -27,40 +26,86 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass()( "ManagedEndpointProvisioningNotConfigured", - {}, + { + userId: Schema.String, + environmentId: Schema.String, + missingSettings: Schema.Array( + Schema.Literals(["managedEndpointBaseDomain", "managedEndpointNamespace"]), + ), + }, ) { override get message(): string { - return "Managed endpoint provisioning is not configured"; + return `Managed endpoint provisioning is not configured for user '${this.userId}', environment '${this.environmentId}': missing ${this.missingSettings.join(", ")}`; } } +const ManagedEndpointProvisioningStage = Schema.Literals([ + "derive-environment-hash", + "reserve-allocation", + "ensure-tunnel", + "validate-tunnel-response", + "record-tunnel", + "configure-tunnel", + "ensure-dns-record", + "record-dns", + "get-tunnel-token", + "mark-allocation-ready", +]); + export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass()( "ManagedEndpointProvisioningFailed", - { cause: Schema.Defect() }, + { + stage: ManagedEndpointProvisioningStage, + userId: Schema.String, + environmentId: Schema.String, + hostname: Schema.optionalKey(Schema.String), + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + returnedTunnelName: Schema.optionalKey(Schema.String), + returnedTunnelId: Schema.optionalKey(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, ) { override get message(): string { - return "Managed endpoint provisioning failed"; + return `Managed endpoint provisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } +const ManagedEndpointDeprovisioningStage = Schema.Literals([ + "load-allocation", + "delete-dns-record", + "delete-tunnel", + "remove-allocation", +]); + export class ManagedEndpointDeprovisioningFailed extends Schema.TaggedErrorClass()( "ManagedEndpointDeprovisioningFailed", - { cause: Schema.Defect() }, + { + stage: ManagedEndpointDeprovisioningStage, + userId: Schema.String, + environmentId: Schema.String, + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint deprovisioning failed"; + return `Managed endpoint deprovisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } export class ManagedEndpointOriginNotAllowed extends Schema.TaggedErrorClass()( "ManagedEndpointOriginNotAllowed", { + userId: Schema.String, + environmentId: Schema.String, host: Schema.String, port: Schema.Number, }, ) { override get message(): string { - return `Managed endpoint origin '${this.host}:${this.port}' is not allowed`; + return `Managed endpoint origin '${this.host}:${this.port}' is not allowed for user '${this.userId}', environment '${this.environmentId}'`; } } @@ -94,12 +139,26 @@ interface ManagedEndpointTunnel { readonly name?: string | null; } +const ManagedEndpointTunnelClientOperation = Schema.Literals([ + "list", + "create", + "put-configuration", + "get-token", + "delete", +]); + export class ManagedEndpointTunnelClientError extends Schema.TaggedErrorClass()( "ManagedEndpointTunnelClientError", - { cause: Schema.Defect() }, + { + operation: ManagedEndpointTunnelClientOperation, + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint tunnel provider request failed"; + const target = this.tunnelId ?? this.tunnelName; + return `Managed endpoint tunnel provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`; } } @@ -147,12 +206,25 @@ interface ManagedEndpointCnameRecordInput { readonly proxied: true; } +const ManagedEndpointDnsClientOperation = Schema.Literals([ + "list-records", + "create-record", + "update-record", + "delete-record", +]); + export class ManagedEndpointDnsClientError extends Schema.TaggedErrorClass()( "ManagedEndpointDnsClientError", - { cause: Schema.Defect() }, + { + operation: ManagedEndpointDnsClientOperation, + hostname: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint DNS provider request failed"; + const target = this.dnsRecordId ?? this.hostname; + return `Managed endpoint DNS provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`; } } @@ -183,13 +255,26 @@ export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) => const requireCloudflareSettings = Effect.fnUntraced(function* ( settings: RelayConfiguration.RelayConfiguration["Service"], + input: { readonly userId: string; readonly environmentId: string }, ) { - if (!settings.managedEndpointBaseDomain || !settings.managedEndpointNamespace) { - return yield* new ManagedEndpointProvisioningNotConfigured(); + const baseDomain = settings.managedEndpointBaseDomain; + const namespace = settings.managedEndpointNamespace; + const missingSettings: Array<"managedEndpointBaseDomain" | "managedEndpointNamespace"> = []; + if (!baseDomain) { + missingSettings.push("managedEndpointBaseDomain"); + } + if (!namespace) { + missingSettings.push("managedEndpointNamespace"); + } + if (!baseDomain || !namespace) { + return yield* new ManagedEndpointProvisioningNotConfigured({ + ...input, + missingSettings, + }); } return { - baseDomain: settings.managedEndpointBaseDomain, - namespace: settings.managedEndpointNamespace, + baseDomain, + namespace, }; }); @@ -231,10 +316,19 @@ function isNotFoundCause(cause: unknown): boolean { return "cause" in cause && isNotFoundCause(cause.cause); } -const ignoreNotFound = (effect: Effect.Effect): Effect.Effect => +type ManagedEndpointClientError = ManagedEndpointTunnelClientError | ManagedEndpointDnsClientError; + +const ignoreNotFound = ( + effect: Effect.Effect, +): Effect.Effect => effect.pipe( Effect.asVoid, - Effect.catch((cause) => (isNotFoundCause(cause) ? Effect.void : Effect.fail(cause))), + Effect.catchTags({ + ManagedEndpointTunnelClientError: (error) => + isNotFoundCause(error.cause) ? Effect.void : Effect.fail(error), + ManagedEndpointDnsClientError: (error) => + isNotFoundCause(error.cause) ? Effect.void : Effect.fail(error), + }), ); const make = Effect.gen(function* () { @@ -289,25 +383,26 @@ const make = Effect.gen(function* () { } return yield* dns.createRecord(dnsRecord).pipe( Effect.map((record) => record.id), - Effect.catch((createError) => - Effect.gen(function* () { - let records = yield* dns.listRecords(hostname); - for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) { - yield* Effect.sleep("200 millis"); - records = yield* dns.listRecords(hostname); - } - return records; - }).pipe( - Effect.flatMap((records) => - records.length > 0 - ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) - : Effect.fail(createError), - ), - Effect.flatMap((dnsRecordId) => - dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), + Effect.catchTags({ + ManagedEndpointDnsClientError: (createError) => + Effect.gen(function* () { + let records = yield* dns.listRecords(hostname); + for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) { + yield* Effect.sleep("200 millis"); + records = yield* dns.listRecords(hostname); + } + return records; + }).pipe( + Effect.flatMap((records) => + records.length > 0 + ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) + : Effect.fail(createError), + ), + Effect.flatMap((dnsRecordId) => + dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), + ), ), - ), - ), + }), ); }); @@ -317,25 +412,59 @@ const make = Effect.gen(function* () { "relay.user_id": input.userId, "relay.environment_id": input.environmentId, }); - const allocation = yield* allocations - .get(input) - .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); + const allocation = yield* allocations.get(input).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "load-allocation", + cause, + }), + ), + ); if (allocation === null) { return; } - if (allocation.dnsRecordId !== null) { - yield* ignoreNotFound(dns.deleteRecord(allocation.dnsRecordId)).pipe( - Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), + const dnsRecordId = allocation.dnsRecordId; + if (dnsRecordId !== null) { + yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "delete-dns-record", + dnsRecordId, + cause, + }), + ), ); } - if (allocation.tunnelId !== null) { - yield* ignoreNotFound(tunnels.delete(allocation.tunnelId)).pipe( - Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), + const tunnelId = allocation.tunnelId; + if (tunnelId !== null) { + yield* ignoreNotFound(tunnels.delete(tunnelId)).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "delete-tunnel", + tunnelId, + cause, + }), + ), ); } - yield* allocations - .remove(input) - .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); + yield* allocations.remove(input).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "remove-allocation", + ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }), + ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), + cause, + }), + ), + ); }), provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -346,11 +475,13 @@ const make = Effect.gen(function* () { }); if (!isLoopbackOrigin(input.origin)) { return yield* new ManagedEndpointOriginNotAllowed({ + userId: input.userId, + environmentId: input.environmentId, host: input.origin.localHttpHost, port: input.origin.localHttpPort, }); } - const cf = yield* requireCloudflareSettings(config); + const cf = yield* requireCloudflareSettings(config, input); const environmentHash = yield* crypto .digest( "SHA-256", @@ -360,19 +491,45 @@ const make = Effect.gen(function* () { ) .pipe( Effect.map(Encoding.encodeHex), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "derive-environment-hash", + cause, + }), + ), ); + const requestedHostname = managedEndpointHostname( + cf.namespace, + cf.baseDomain, + environmentHash, + ); + const requestedTunnelName = managedEndpointTunnelName(cf.namespace, environmentHash); const allocation = yield* allocations .reserve({ userId: input.userId, environmentId: input.environmentId, - hostname: managedEndpointHostname(cf.namespace, cf.baseDomain, environmentHash), - tunnelName: managedEndpointTunnelName(cf.namespace, environmentHash), + hostname: requestedHostname, + tunnelName: requestedTunnelName, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "reserve-allocation", + hostname: requestedHostname, + tunnelName: requestedTunnelName, + cause, + }), + ), + ); const { hostname, tunnelName } = allocation; - const tunnel = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( + const tunnelResponse = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( Effect.map((tunnels) => tunnels.result), Effect.map(Arr.findFirst((tunnel) => tunnel.name === tunnelName)), Effect.flatMap( @@ -381,20 +538,50 @@ const make = Effect.gen(function* () { onNone: () => tunnels.create({ name: tunnelName, configSrc: "cloudflare" }), }), ), - Effect.filterMapOrFail((tunnel) => - tunnel.id && tunnel.name - ? Result.succeed({ id: tunnel.id, name: tunnel.name }) - : Result.fail(new ManagedEndpointProvisioningFailed({ cause: tunnel })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "ensure-tunnel", + hostname, + tunnelName, + cause, + }), ), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), ); + if (!tunnelResponse.id || !tunnelResponse.name) { + return yield* new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "validate-tunnel-response", + hostname, + tunnelName, + ...(tunnelResponse.id ? { returnedTunnelId: tunnelResponse.id } : {}), + ...(tunnelResponse.name ? { returnedTunnelName: tunnelResponse.name } : {}), + }); + } + const tunnel = { id: tunnelResponse.id, name: tunnelResponse.name }; yield* allocations .recordTunnel({ userId: input.userId, environmentId: input.environmentId, tunnelId: tunnel.id, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "record-tunnel", + hostname, + tunnelName, + tunnelId: tunnel.id, + cause, + }), + ), + ); yield* tunnels .putConfiguration(tunnel.id, { @@ -406,7 +593,20 @@ const make = Effect.gen(function* () { { service: "http_status:404" }, ], }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "configure-tunnel", + hostname, + tunnelName, + tunnelId: tunnel.id, + cause, + }), + ), + ); const dnsRecord = { type: "CNAME", @@ -417,7 +617,19 @@ const make = Effect.gen(function* () { } as const; const dnsRecordId = yield* ensureDnsRecord(hostname, allocation.dnsRecordId, dnsRecord).pipe( - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "ensure-dns-record", + hostname, + tunnelName, + tunnelId: tunnel.id, + ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), + cause, + }), + ), ); yield* allocations .recordDns({ @@ -425,17 +637,57 @@ const make = Effect.gen(function* () { environmentId: input.environmentId, dnsRecordId, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "record-dns", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); - const connectorToken = yield* tunnels - .getToken(tunnel.id) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + const connectorToken = yield* tunnels.getToken(tunnel.id).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "get-tunnel-token", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); yield* allocations .markReady({ userId: input.userId, environmentId: input.environmentId, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "mark-allocation-ready", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); return { endpoint: managedEndpointForHostname(hostname), @@ -464,27 +716,62 @@ export const layerCloudflareBindings = ( ManagedEndpointTunnelClient.of({ list: (request) => tunnelClient.list(request).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "list", + tunnelName: request.name, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), create: (request) => tunnelClient.create(request).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "create", + tunnelName: request.name, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), putConfiguration: (tunnelId, config) => tunnelClient.putConfiguration(tunnelId, config).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "put-configuration", + tunnelId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), getToken: (tunnelId) => tunnelClient.getToken(tunnelId).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "get-token", + tunnelId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), delete: (tunnelId) => tunnelClient.delete(tunnelId).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), }), @@ -500,23 +787,52 @@ export const layerCloudflareBindings = ( normalizeHostname(record.name) === normalizeHostname(hostname), ), ), - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "list-records", + hostname, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), createRecord: (request) => dnsClient.createDnsRecord(request).pipe( Effect.map((response) => ({ id: response.id })), - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "create-record", + hostname: request.name, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), updateRecord: (dnsRecordId, request) => dnsClient.updateDnsRecord(dnsRecordId, request).pipe( - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "update-record", + hostname: request.name, + dnsRecordId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), deleteRecord: (dnsRecordId) => dnsClient.deleteDnsRecord(dnsRecordId).pipe( - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "delete-record", + dnsRecordId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), }), From 569b3e5959c19e87fec28480c40011eae2e465dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:31:49 -0700 Subject: [PATCH 171/257] [codex] Structure desktop SSH prompt presentation failures (#3429) Co-authored-by: codex --- .../src/ssh/DesktopSshEnvironment.test.ts | 1 + .../src/ssh/DesktopSshPasswordPrompts.test.ts | 111 ++++++++++++- .../src/ssh/DesktopSshPasswordPrompts.ts | 156 ++++++++++++++---- 3 files changed, 230 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index baed2610286..1fe2b86aae7 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -23,6 +23,7 @@ describe("sshEnvironment", () => { const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({ requestId: "prompt-1", destination: "devbox", + operation: "send-prompt-request", cause: new Error("renderer send failed"), }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index f0b5b1bd8ef..5ec7dd65d1e 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -17,7 +17,13 @@ interface SentMessage { readonly args: readonly unknown[]; } -function makeTestWindow() { +function makeTestWindow( + options: { + readonly isDestroyedError?: unknown; + readonly isMinimizedError?: unknown; + readonly sendError?: unknown; + } = {}, +) { const listeners = new Map void>>(); const sentMessages: SentMessage[] = []; let destroyed = false; @@ -26,8 +32,18 @@ function makeTestWindow() { let focused = false; const window = { - isDestroyed: () => destroyed, - isMinimized: () => minimized, + isDestroyed: () => { + if (options.isDestroyedError !== undefined) { + throw options.isDestroyedError; + } + return destroyed; + }, + isMinimized: () => { + if (options.isMinimizedError !== undefined) { + throw options.isMinimizedError; + } + return minimized; + }, restore: () => { restored = true; minimized = false; @@ -45,7 +61,11 @@ function makeTestWindow() { }, webContents: { send: (channel: string, ...args: readonly unknown[]) => { - sentMessages.push({ channel, args }); + const message = { channel, args }; + sentMessages.push(message); + if (options.sendError !== undefined) { + throw options.sendError; + } }, }, }; @@ -55,6 +75,7 @@ function makeTestWindow() { sentMessages, isRestored: () => restored, isFocused: () => focused, + closedListenerCount: () => listeners.get("closed")?.size ?? 0, close: () => { destroyed = true; const closedListeners = [...(listeners.get("closed") ?? [])]; @@ -107,6 +128,7 @@ describe("DesktopSshPasswordPrompts", () => { }) .pipe(Effect.forkScoped); + yield* Effect.yieldNow; yield* Effect.yieldNow; assert.equal(testWindow.sentMessages.length, 1); const sent = testWindow.sentMessages[0]; @@ -143,4 +165,85 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(error.destination, "devbox"); }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); }); + + it.effect("cleans up a prompt that fails during renderer delivery", () => { + const cause = new Error("renderer unavailable"); + const testWindow = makeTestWindow({ sendError: cause }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const error = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); + assert.equal(error.operation, "send-prompt-request"); + assert.equal(error.destination, "devbox"); + const requestId = error.requestId; + if (requestId === null) { + assert.fail("renderer delivery failures must retain their request id"); + } + assert.equal(testWindow.closedListenerCount(), 0); + + const resolveError = yield* prompts + .resolve({ requestId, password: "secret" }) + .pipe(Effect.flip); + assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("keeps a submitted password when a later presentation step fails", () => { + const testWindow = makeTestWindow({ + isMinimizedError: new Error("failed to read minimized state"), + }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const requestFiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + const sent = testWindow.sentMessages[0]; + assert.ok(sent); + const request = sent.args[0] as { readonly requestId: string }; + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + const password = yield* Fiber.join(requestFiber); + + assert.equal(password, "secret"); + assert.equal(testWindow.isFocused(), false); + assert.equal(testWindow.closedListenerCount(), 0); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("classifies a failed initial window availability check", () => { + const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const error = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); + assert.equal(error.operation, "check-window-before-request"); + assert.equal(error.requestId, null); + assert.deepEqual(testWindow.sentMessages, []); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index c933bca3cb0..aa25d8135c7 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -20,7 +20,26 @@ const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; type DesktopSshPasswordPromptResolutionInput = typeof DesktopSshPasswordPromptResolutionInputSchema.Type; -const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; +const DesktopSshPromptWindowAvailabilityStage = Schema.Literals([ + "before-request", + "before-presentation", + "after-send", + "after-restore", +]); + +const DesktopSshPromptPresentationOperation = Schema.Literals([ + "check-window-before-request", + "check-window-before-presentation", + "register-window-close-listener", + "send-prompt-request", + "check-window-after-send", + "check-window-minimized", + "restore-window", + "check-window-after-restore", + "focus-window", + "remove-window-close-listener", +]); +type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type; export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass()( "DesktopSshPromptRequestIdGenerationError", @@ -38,20 +57,22 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl "DesktopSshPromptWindowUnavailableError", { destination: Schema.String, + requestId: Schema.NullOr(Schema.String), + stage: DesktopSshPromptWindowAvailabilityStage, }, ) { override get message(): string { - return WINDOW_UNAVAILABLE_MESSAGE; + const request = this.requestId === null ? "before a request id was assigned" : this.requestId; + return `T3 Code window is unavailable during ${this.stage} for SSH authentication to ${this.destination} (request: ${request}).`; } } -const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError); - export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass()( "DesktopSshPromptPresentationError", { - requestId: Schema.String, + requestId: Schema.NullOr(Schema.String), destination: Schema.String, + operation: DesktopSshPromptPresentationOperation, cause: Schema.Defect(), }, ) { @@ -263,9 +284,29 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( "desktop.sshPasswordPrompts.request", )(function* (input) { const window = yield* electronWindow.main; - if (Option.isNone(window) || window.value.isDestroyed()) { + if (Option.isNone(window)) { return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId: null, + stage: "before-request", + }); + } + + const unavailableBeforeRequest = yield* Effect.try({ + try: () => window.value.isDestroyed(), + catch: (cause) => + new DesktopSshPromptPresentationError({ + requestId: null, + destination: input.destination, + operation: "check-window-before-request", + cause, + }), + }); + if (unavailableBeforeRequest) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId: null, + stage: "before-request", }); } @@ -319,11 +360,25 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( ), ); }; - const cleanup = Effect.sync(() => { + const runPresentationOperation = ( + operation: DesktopSshPromptPresentationOperation, + evaluate: () => A, + ) => + Effect.try({ + try: evaluate, + catch: (cause) => + new DesktopSshPromptPresentationError({ + requestId, + destination: input.destination, + operation, + cause, + }), + }); + const cleanup = runPresentationOperation("remove-window-close-listener", () => { if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); + }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid); const waitForPassword = Deferred.await(deferred).pipe( Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), Effect.flatMap( @@ -339,40 +394,73 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( }), ), ); + const preferSubmittedPassword = (error: DesktopSshPasswordPromptRequestError) => + Deferred.poll(deferred).pipe( + Effect.flatMap( + Option.match({ + onSome: (completion) => completion, + onNone: () => + Ref.get(pendingRef).pipe( + Effect.flatMap((entries) => + entries.has(requestId) ? Effect.fail(error) : Deferred.await(deferred), + ), + ), + }), + ), + ); - return yield* Effect.try({ - try: () => { - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ - destination: input.destination, - }); - } - window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ + return yield* Effect.gen(function* () { + const unavailableBeforePresentation = yield* runPresentationOperation( + "check-window-before-presentation", + () => window.value.isDestroyed(), + ); + if (unavailableBeforePresentation) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId, + stage: "before-presentation", + }); + } + yield* runPresentationOperation("register-window-close-listener", () => + window.value.once("closed", cancelOnWindowClosed), + ); + return yield* Effect.gen(function* () { + yield* runPresentationOperation("send-prompt-request", () => + window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest), + ); + yield* Effect.yieldNow; + const unavailableAfterSend = yield* runPresentationOperation( + "check-window-after-send", + () => window.value.isDestroyed(), + ); + if (unavailableAfterSend) { + return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId, + stage: "after-send", }); } - if (window.value.isMinimized()) { - window.value.restore(); + const minimized = yield* runPresentationOperation("check-window-minimized", () => + window.value.isMinimized(), + ); + if (minimized) { + yield* runPresentationOperation("restore-window", () => window.value.restore()); } - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ + const unavailableAfterRestore = yield* runPresentationOperation( + "check-window-after-restore", + () => window.value.isDestroyed(), + ); + if (unavailableAfterRestore) { + return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId, + stage: "after-restore", }); } - window.value.focus(); - }, - catch: (cause) => - isDesktopSshPromptWindowUnavailableError(cause) - ? cause - : new DesktopSshPromptPresentationError({ - requestId, - destination: input.destination, - cause, - }), - }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); + yield* runPresentationOperation("focus-window", () => window.value.focus()); + return yield* waitForPassword; + }).pipe(Effect.catch(preferSubmittedPassword)); + }).pipe(Effect.ensuring(cleanup)); }); return DesktopSshPasswordPrompts.of({ From 630df6b986f6e254d3c564da639de5b7b52fe95d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:32:34 -0700 Subject: [PATCH 172/257] [codex] Enrich source-control errors (#3248) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 16 ++ .../src/sourceControl/AzureDevOpsCli.test.ts | 23 +++ .../src/sourceControl/AzureDevOpsCli.ts | 128 ++++++++------ .../AzureDevOpsSourceControlProvider.test.ts | 40 +++++ .../AzureDevOpsSourceControlProvider.ts | 130 +++++++++++--- .../BitbucketSourceControlProvider.test.ts | 39 +++++ .../BitbucketSourceControlProvider.ts | 121 +++++++++++--- .../src/sourceControl/GitHubCli.test.ts | 25 +-- apps/server/src/sourceControl/GitHubCli.ts | 126 ++++++++------ .../GitHubSourceControlProvider.test.ts | 43 +++++ .../GitHubSourceControlProvider.ts | 158 ++++++++++++++---- .../src/sourceControl/GitLabCli.test.ts | 23 +-- apps/server/src/sourceControl/GitLabCli.ts | 147 +++++++++------- .../GitLabSourceControlProvider.test.ts | 44 +++++ .../GitLabSourceControlProvider.ts | 132 ++++++++++++--- .../SourceControlProvider.test.ts | 19 +++ .../sourceControl/SourceControlProvider.ts | 30 ++++ .../SourceControlProviderRegistry.test.ts | 70 ++++++-- .../SourceControlProviderRegistry.ts | 105 ++++++++---- packages/contracts/src/sourceControl.ts | 4 + 20 files changed, 1084 insertions(+), 339 deletions(-) create mode 100644 apps/server/src/sourceControl/SourceControlProvider.test.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index d1cfc7d2a13..14490765a9d 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -173,6 +173,8 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { } throw new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd, detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, }); } @@ -480,6 +482,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ? error : new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: input.cwd, detail: error instanceof Error ? `Failed to simulate gh checkout: ${error.message}` @@ -496,6 +500,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return Effect.fail( new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: input.cwd, detail: `Unexpected repository lookup: ${repository}`, }), ); @@ -516,6 +522,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return Effect.fail( new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: input.cwd, detail: `Unexpected gh command: ${args.join(" ")}`, }), ); @@ -595,6 +603,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.fail( new GitHubCli.GitHubCliError({ operation: "createRepository", + command: "gh", + cwd: input.cwd, detail: `Unexpected repository create: ${input.repository}`, }), ), @@ -1333,6 +1343,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { failWith: new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: repoDir, detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), }, @@ -2471,6 +2483,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { failWith: new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: repoDir, detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), }, @@ -2500,6 +2514,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { failWith: new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: repoDir, detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", }), }, diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 52aedd1d760..8617f14e365 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessExitError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -329,4 +330,26 @@ describe("AzureDevOpsCli.layer", () => { }); }).pipe(Effect.provide(layer)), ); + + it.effect("preserves VCS causes without copying upstream details into messages", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "AzureDevOpsCli.execute", + command: "az repos list --organization sensitive-upstream-detail", + cwd: "/repo", + exitCode: 1, + detail: "sensitive-upstream-detail", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("sensitive-upstream-detail"), false); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 442cae68934..ea0e5286872 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility, @@ -14,7 +13,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeAzureDevOpsPullRequestJson, decodeAzureDevOpsPullRequestListJson, - formatAzureDevOpsJsonDecodeError, type NormalizedAzureDevOpsPullRequestRecord, } from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -25,6 +23,8 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass( schema: S, operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", invalidDetail: string, + cwd: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( (error) => new AzureDevOpsCliError({ operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + command: "az", + cwd, + detail: invalidDetail, cause: error, }), ), @@ -249,7 +255,14 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); + .pipe( + Effect.mapError((error) => + AzureDevOpsCliError.fromVcsError( + { operation: "execute", command: "az", cwd: input.cwd }, + error, + ), + ), + ); const executeJson = (input: Parameters[0]) => execute({ @@ -286,7 +299,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new AzureDevOpsCliError({ operation: "listPullRequests", - detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + command: "az", + cwd: input.cwd, + detail: "Azure DevOps CLI returned invalid PR list JSON.", cause: decoded.failure, }), ); @@ -318,7 +333,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new AzureDevOpsCliError({ operation: "getPullRequest", - detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + command: "az", + cwd: input.cwd, + detail: "Azure DevOps CLI returned invalid pull request JSON.", cause: decoded.failure, }), ); @@ -341,6 +358,7 @@ export const make = Effect.gen(function* () { RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", "Azure DevOps CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -370,6 +388,7 @@ export const make = Effect.gen(function* () { RawAzureDevOpsRepositorySchema, "createRepository", "Azure DevOps CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -407,6 +426,7 @@ export const make = Effect.gen(function* () { RawAzureDevOpsRepositorySchema, "getDefaultBranch", "Azure DevOps CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index f007ecf7985..1341f4cc08d 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -46,6 +46,46 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); +it.effect("adds change-request context while retaining Azure CLI causes", () => + Effect.gen(function* () { + const cause = new AzureDevOpsCli.AzureDevOpsCliError({ + operation: "execute", + command: "az", + cwd: "/repo", + detail: "Azure DevOps CLI command failed.", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + checkoutPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: "az", + cwd: "/repo", + reference: "#42", + detail: "Azure DevOps CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("creates Azure DevOps PRs through provider-neutral input names", () => Effect.gen(function* () { let createInput: diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8cd5bd7522d..bf2ac982927 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -12,18 +12,6 @@ import { type SourceControlCliDiscoverySpec, } from "./SourceControlProviderDiscovery.ts"; -function providerError( - operation: string, - cause: AzureDevOpsCli.AzureDevOpsCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "azure-devops", - operation, - detail: cause.detail, - cause, - }); -} - function parseAzureAuth(input: SourceControlAuthProbeInput) { const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); @@ -101,13 +89,39 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); }, getChangeRequest: (input) => azure.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -121,20 +135,71 @@ export const make = Effect.gen(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - azure - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + azure.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - azure - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + azure.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - azure - .getDefaultBranch({ cwd: input.cwd }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + azure.getDefaultBranch({ cwd: input.cwd }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => azure .checkoutPullRequest({ @@ -142,7 +207,22 @@ export const make = Effect.gen(function* () { reference: input.reference, ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), }) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 8530e163dc6..75ac877cd43 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -51,6 +51,45 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( }), ); +it.effect("adds repository context while retaining Bitbucket API causes", () => + Effect.gen(function* () { + const cause = new BitbucketApi.BitbucketApiError({ + operation: "getRepository", + detail: "upstream detail that should remain in the cause", + status: 503, + cause: new Error("raw upstream failure"), + }); + const provider = yield* makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(cause), + }); + + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + command: undefined, + cwd: "/repo", + repository: "owner/repo", + detail: "Failed to get repository clone URLs.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); + }), +); + it.effect("lists Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { let listInput: Parameters[0] | null = diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 6c1d67434bf..974fbb94a39 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -8,18 +8,6 @@ import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullReques import * as SourceControlProvider from "./SourceControlProvider.ts"; import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; -function providerError( - operation: string, - cause: BitbucketApi.BitbucketApiError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "bitbucket", - operation, - detail: cause.detail, - cause, - }); -} - function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { provider: "bitbucket", @@ -60,13 +48,37 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "listChangeRequests", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: "Failed to list change requests.", + cause: error, + }), + ), ); }, getChangeRequest: (input) => bitbucket.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to get change request.", + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -81,23 +93,72 @@ export const make = Effect.gen(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "createChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: "Failed to create change request.", + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - bitbucket - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + bitbucket.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Failed to get repository clone URLs.", + cause: error, + }), + ), + ), createRepository: (input) => - bitbucket - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + bitbucket.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "createRepository", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Failed to create repository.", + cause: error, + }), + ), + ), getDefaultBranch: (input) => bitbucket .getDefaultBranch({ cwd: input.cwd, ...(input.context ? { context: input.context } : {}), }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getDefaultBranch", + cwd: input.cwd, + detail: "Failed to get default branch.", + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => bitbucket .checkoutPullRequest({ @@ -106,7 +167,21 @@ export const make = Effect.gen(function* () { reference: input.reference, ...(input.force !== undefined ? { force: input.force } : {}), }) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to check out change request.", + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index e0e781bd8b5..7c8c9b037be 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -269,18 +269,15 @@ describe("GitHubCli.layer", () => { it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.fail( - new VcsProcessExitError({ - operation: "GitHubCli.execute", - command: "gh pr view", - cwd: "/repo", - exitCode: 1, - detail: - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - }), - ), - ); + const cause = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + detail: + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); const gh = yield* GitHubCli.GitHubCli; const error = yield* gh @@ -291,6 +288,10 @@ describe("GitHubCli.layer", () => { .pipe(Effect.flip); assert.equal(error.message.includes("Pull request not found"), true); + assert.strictEqual(error.command, "gh"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 836c7e1eb74..4cdf38ec2b8 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { TrimmedNonEmptyString, @@ -15,19 +14,71 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, - formatGitHubJsonDecodeError, } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { operation: Schema.String, + command: Schema.String, + cwd: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { override get message(): string { return `GitHub CLI failed in ${this.operation}: ${this.detail}`; } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "gh"; + readonly cwd: string; + }, + error: VcsError | unknown, + ): GitHubCliError { + const lower = errorText(error).toLowerCase(); + + if (lower.includes("command not found: gh") || lower.includes("enoent")) { + return new GitHubCliError({ + ...context, + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("gh auth login") || + lower.includes("no oauth token") + ) { + return new GitHubCliError({ + ...context, + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("could not resolve to a pullrequest") || + lower.includes("repository.pullrequest") || + lower.includes("no pull requests found for branch") || + lower.includes("pull request not found") + ) { + return new GitHubCliError({ + ...context, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + + return new GitHubCliError({ + ...context, + detail: "GitHub CLI command failed.", + cause: error, + }); + } } export interface GitHubPullRequestSummary { @@ -110,54 +161,6 @@ function errorText(error: VcsError | unknown): string { return String(error); } -function normalizeGitHubCliError( - operation: "execute" | "stdout", - error: VcsError | unknown, -): GitHubCliError { - const text = errorText(error); - const lower = text.toLowerCase(); - - if (lower.includes("command not found: gh") || lower.includes("enoent")) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); - } - - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") - ) { - return new GitHubCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); - } - - return new GitHubCliError({ - operation, - detail: text, - cause: error, - }); -} - const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, @@ -216,13 +219,16 @@ function decodeGitHubJson( schema: S, operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", invalidDetail: string, + cwd: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( (error) => new GitHubCliError({ operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + command: "gh", + cwd, + detail: invalidDetail, cause: error, }), ), @@ -241,7 +247,14 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); + .pipe( + Effect.mapError((error) => + GitHubCliError.fromVcsError( + { operation: "execute", command: "gh", cwd: input.cwd }, + error, + ), + ), + ); return GitHubCli.of({ execute, @@ -271,7 +284,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new GitHubCliError({ operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + command: "gh", + cwd: input.cwd, + detail: "GitHub CLI returned invalid PR list JSON.", cause: decoded.failure, }), ); @@ -303,7 +318,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new GitHubCliError({ operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + command: "gh", + cwd: input.cwd, + detail: "GitHub CLI returned invalid pull request JSON.", cause: decoded.failure, }), ); @@ -328,6 +345,7 @@ export const make = Effect.gen(function* () { RawGitHubRepositoryCloneUrlsSchema, "getRepositoryCloneUrls", "GitHub CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 141672c91c5..c1aa8680b26 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -68,6 +68,49 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); +it.effect("adds safe request context while retaining GitHub CLI causes", () => + Effect.gen(function* () { + const cause = new GitHubCli.GitHubCliError({ + operation: "execute", + command: "gh", + cwd: "/repo", + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + getPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "github", + operation: "getChangeRequest", + command: "gh", + cwd: "/repo", + reference: "https://github.com/pingdotgg/t3code/pull/42", + detail: "Pull request not found. Check the PR number or URL and try again.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("uses gh json listing for non-open change request state queries", () => Effect.gen(function* () { let executeArgs: ReadonlyArray = []; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b84d2504f93..60298888e6c 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; import { SourceControlProviderError, type ChangeRequest, @@ -20,19 +19,6 @@ import { type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, } from "./SourceControlProviderDiscovery.ts"; -const isSourceControlProviderError = Schema.is(SourceControlProviderError); - -function providerError( - operation: string, - cause: GitHubCli.GitHubCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "github", - operation, - detail: cause.detail, - cause, - }); -} function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { return { @@ -122,7 +108,20 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); } @@ -162,6 +161,11 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", + command: "gh", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), detail: "GitHub CLI returned invalid change request JSON.", cause: decoded.failure, }), @@ -169,11 +173,20 @@ export const make = Effect.gen(function* () { ), ); }), - Effect.mapError((error) => - isSourceControlProviderError(error) - ? error - : providerError("listChangeRequests", error), - ), + Effect.catchTags({ + GitHubCliError: (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + }), ); }; @@ -183,7 +196,20 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => github.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => github @@ -194,23 +220,87 @@ export const make = Effect.gen(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ), getRepositoryCloneUrls: (input) => - github - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + github.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - github - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + github.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - github - .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + github.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => - github - .checkoutPullRequest(input) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + github.checkoutPullRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index f7c3b3e4bf0..792e3a82b13 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -313,17 +313,14 @@ layer("GitLabCli.layer", (it) => { it.effect("surfaces a friendly error when the merge request is not found", () => Effect.gen(function* () { - mockedRun.mockReturnValueOnce( - Effect.fail( - new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab mr view 4888", - cwd: "/repo", - exitCode: 1, - detail: "GET 404 merge request not found", - }), - ), - ); + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab mr view 4888", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 merge request not found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); const error = yield* Effect.gen(function* () { const glab = yield* GitLabCli.GitLabCli; @@ -334,6 +331,10 @@ layer("GitLabCli.layer", (it) => { }).pipe(Effect.flip); assert.equal(error.message.includes("Merge request not found"), true); + assert.strictEqual(error.command, "glab"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); }), ); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index c5fd7ee52f0..b34e72ffc95 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -4,16 +4,18 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import type * as DateTime from "effect/DateTime"; -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeGitLabMergeRequestJson, decodeGitLabMergeRequestListJson, - formatGitLabJsonDecodeError, } from "./gitLabMergeRequests.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -21,12 +23,64 @@ const DEFAULT_TIMEOUT_MS = 30_000; export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { operation: Schema.String, + command: Schema.String, + cwd: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { override get message(): string { return `GitLab CLI failed in ${this.operation}: ${this.detail}`; } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + }, + error: VcsError | unknown, + ): GitLabCliError { + const lower = errorText(error).toLowerCase(); + + if (lower.includes("command not found: glab") || isVcsProcessSpawnError(error)) { + return new GitLabCliError({ + ...context, + detail: "GitLab CLI (`glab`) is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("glab auth login") || + lower.includes("token") + ) { + return new GitLabCliError({ + ...context, + detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("merge request not found") || + lower.includes("not found") || + lower.includes("404") + ) { + return new GitLabCliError({ + ...context, + detail: "Merge request not found. Check the MR number or URL and try again.", + cause: error, + }); + } + + return new GitLabCliError({ + ...context, + detail: "GitLab CLI command failed.", + cause: error, + }); + } } export interface GitLabMergeRequestSummary { @@ -48,6 +102,17 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + export class GitLabCli extends Context.Service< GitLabCli, { @@ -112,56 +177,6 @@ function isVcsProcessSpawnError(error: unknown): boolean { ); } -function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { - if (error instanceof Error) { - if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) { - return new GitLabCliError({ - operation, - detail: "GitLab CLI (`glab`) is required but not available on PATH.", - cause: error, - }); - } - - const lower = error.message.toLowerCase(); - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("glab auth login") || - lower.includes("token") - ) { - return new GitLabCliError({ - operation, - detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("merge request not found") || - lower.includes("not found") || - lower.includes("404") - ) { - return new GitLabCliError({ - operation, - detail: "Merge request not found. Check the MR number or URL and try again.", - cause: error, - }); - } - - return new GitLabCliError({ - operation, - detail: `GitLab CLI command failed: ${error.message}`, - cause: error, - }); - } - - return new GitLabCliError({ - operation, - detail: "GitLab CLI command failed.", - cause: error, - }); -} - const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ path_with_namespace: TrimmedNonEmptyString, web_url: TrimmedNonEmptyString, @@ -192,13 +207,16 @@ function decodeGitLabJson( schema: S, operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", invalidDetail: string, + cwd: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( (error) => new GitLabCliError({ operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + command: "glab", + cwd, + detail: invalidDetail, cause: error, }), ), @@ -274,7 +292,14 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); + .pipe( + Effect.mapError((error) => + GitLabCliError.fromVcsError( + { operation: "execute", command: "glab", cwd: input.cwd }, + error, + ), + ), + ); return GitLabCli.of({ execute, @@ -303,7 +328,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new GitLabCliError({ operation: "listMergeRequests", - detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + command: "glab", + cwd: input.cwd, + detail: "GitLab CLI returned invalid MR list JSON.", cause: decoded.failure, }), ); @@ -327,7 +354,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new GitLabCliError({ operation: "getMergeRequest", - detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + command: "glab", + cwd: input.cwd, + detail: "GitLab CLI returned invalid merge request JSON.", cause: decoded.failure, }), ); @@ -350,6 +379,7 @@ export const make = Effect.gen(function* () { RawGitLabRepositoryCloneUrlsSchema, "getRepositoryCloneUrls", "GitLab CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -368,6 +398,7 @@ export const make = Effect.gen(function* () { RawGitLabNamespaceSchema, "createRepository", "GitLab CLI returned invalid namespace JSON.", + input.cwd, ), ), Effect.map((namespace) => namespace.id), @@ -402,6 +433,7 @@ export const make = Effect.gen(function* () { RawGitLabRepositoryCloneUrlsSchema, "createRepository", "GitLab CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -440,6 +472,7 @@ export const make = Effect.gen(function* () { RawGitLabDefaultBranchSchema, "getDefaultBranch", "GitLab CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map((value) => value.default_branch ?? null), diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 3dc61e132f3..6ab3f23b150 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -52,6 +52,50 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = }), ); +it.effect("adds repository context while retaining GitLab CLI causes", () => + Effect.gen(function* () { + const cause = new GitLabCli.GitLabCliError({ + operation: "execute", + command: "glab", + cwd: "/repo", + detail: "GitLab CLI command failed.", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + createRepository: () => Effect.fail(cause), + }); + + const error = yield* provider + .createRepository({ + cwd: "/repo", + repository: "owner/repo", + visibility: "private", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "gitlab", + operation: "createRepository", + command: "glab", + cwd: "/repo", + repository: "owner/repo", + detail: "GitLab CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("lists GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { let listInput: Parameters[0] | null = null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index d1aaf06309d..2cba12f1b3f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -17,18 +17,6 @@ import { } from "./SourceControlProviderDiscovery.ts"; import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; -function providerError( - operation: string, - cause: GitLabCli.GitLabCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "gitlab", - operation, - detail: cause.detail, - cause, - }); -} - function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { return { provider: "gitlab", @@ -129,13 +117,39 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); }, getChangeRequest: (input) => gitlab.getMergeRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -149,24 +163,88 @@ export const make = Effect.gen(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - gitlab - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + gitlab.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - gitlab - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + gitlab.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - gitlab - .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + gitlab.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => - gitlab - .checkoutMergeRequest(input) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + gitlab.checkoutMergeRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/SourceControlProvider.test.ts b/apps/server/src/sourceControl/SourceControlProvider.test.ts new file mode 100644 index 00000000000..7e532488279 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProvider.test.ts @@ -0,0 +1,19 @@ +import { assert, it } from "@effect/vitest"; + +import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts"; + +it("removes URL credentials, query parameters, and fragments from error transport values", () => { + assert.strictEqual( + transportSafeSourceControlErrorValue( + "https://user:secret@example.test/org/repo/pull/42?token=secret#discussion", + ), + "https://example.test/org/repo/pull/42", + ); +}); + +it("normalizes control characters and bounds error transport values", () => { + assert.strictEqual( + transportSafeSourceControlErrorValue(` owner/repo\n\t${"x".repeat(300)} `), + `owner/repo ${"x".repeat(245)}`, + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index c2959ef878e..5f93dbcaa42 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -22,6 +22,36 @@ export interface SourceControlRefSelector { readonly repository?: string; } +const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; + +/** + * Sanitizes user-provided source-control identifiers before attaching them to + * contract errors. This is intentionally narrower than request validation: it + * only strips URL secrets and bounds diagnostic values sent over transport. + */ +export function transportSafeSourceControlErrorValue(value: string): string { + let printable = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character; + } + const normalized = printable.trim().replace(/\s+/gu, " "); + + let safe = normalized; + try { + const url = new URL(normalized); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + safe = url.toString(); + } catch { + // Plain repository and change-request identifiers are not URLs. + } + + return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); +} + export function parseSourceControlOwnerRef( headSelector: string, ): SourceControlRefSelector | undefined { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 6cea2d9a496..5c4d27e46f9 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; @@ -38,6 +39,7 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; + readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; }) { const driver = { listRemotes: () => @@ -57,21 +59,23 @@ function makeRegistry(input: { const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), - resolve: () => - Effect.succeed({ - kind: "git", - repository: { + resolve: + input.resolve ?? + (() => + Effect.succeed({ kind: "git", - rootPath: "/repo", - metadataPath: null, - freshness: { - source: "live-local" as const, - observedAt: TEST_EPOCH, - expiresAt: Option.none(), + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, }, - }, - driver: driver as unknown as VcsDriver.VcsDriver["Service"], - }), + driver: driver as unknown as VcsDriver.VcsDriver["Service"], + })), }); const processLayer = Layer.mock(VcsProcess.VcsProcess)({ @@ -120,6 +124,46 @@ it.effect("routes directly by provider kind for remote-first workflows", () => }), ); +it.effect("includes the request cwd when an unregistered provider is used", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ remotes: [] }); + const provider = yield* registry.get("unknown"); + + const error = yield* provider + .getChangeRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "getChangeRequest"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.reference, "#42"); + }), +); + +it.effect("retains VCS detection failures with structured cwd context", () => + Effect.gen(function* () { + const cause = new VcsRepositoryDetectionError({ + operation: "resolve", + cwd: "/repo", + detail: "raw VCS detection failure", + cause: new Error("raw nested failure"), + }); + const registry = yield* makeRegistry({ + remotes: [], + resolve: () => Effect.fail(cause), + }); + + const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "detectProvider"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.detail, "Failed to detect source control provider."); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.message), false); + }), +); + it.effect("routes GitLab remotes to the GitLab provider", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index b1f1ea7aae7..fb70d677e43 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -64,33 +64,62 @@ export class SourceControlProviderRegistry extends Context.Service< function unsupportedProvider( kind: SourceControlProviderKind, ): SourceControlProvider.SourceControlProvider["Service"] { - const unsupported = (operation: string) => - Effect.fail( + return SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests: (input) => new SourceControlProviderError({ provider: kind, - operation, + operation: "listChangeRequests", + cwd: input.cwd, + detail: `No ${kind} source control provider is registered.`, + }), + getChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), + detail: `No ${kind} source control provider is registered.`, + }), + createChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "createChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.headSelector), + detail: `No ${kind} source control provider is registered.`, + }), + getRepositoryCloneUrls: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), + detail: `No ${kind} source control provider is registered.`, + }), + createRepository: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "createRepository", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), + detail: `No ${kind} source control provider is registered.`, + }), + getDefaultBranch: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getDefaultBranch", + cwd: input.cwd, + detail: `No ${kind} source control provider is registered.`, + }), + checkoutChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), detail: `No ${kind} source control provider is registered.`, }), - ); - - return SourceControlProvider.SourceControlProvider.of({ - kind, - listChangeRequests: () => unsupported("listChangeRequests"), - getChangeRequest: () => unsupported("getChangeRequest"), - createChangeRequest: () => unsupported("createChangeRequest"), - getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), - createRepository: () => unsupported("createRepository"), - getDefaultBranch: () => unsupported("getDefaultBranch"), - checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), - }); -} - -function providerDetectionError(operation: string, cwd: string, cause: unknown) { - return new SourceControlProviderError({ - provider: "unknown", - operation, - detail: `Failed to detect source control provider for ${cwd}.`, - cause, }); } @@ -180,12 +209,30 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( function* (cwd: string) { - const handle = yield* vcsRegistry - .resolve({ cwd }) - .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); - const remotes = yield* handle.driver - .listRemotes(cwd) - .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + const handle = yield* vcsRegistry.resolve({ cwd }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "unknown", + operation: "detectProvider", + cwd, + detail: "Failed to detect source control provider.", + cause: error, + }), + ), + ); + const remotes = yield* handle.driver.listRemotes(cwd).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "unknown", + operation: "detectProvider", + cwd, + detail: "Failed to detect source control provider.", + cause: error, + }), + ), + ); const context = selectProviderContext(remotes.remotes); return yield* refineUnknownRemoteProvider({ diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 0ecf13c67e6..104aadd9161 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -155,6 +155,10 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 12:33:22 -0700 Subject: [PATCH 173/257] [codex] Sanitize text generation CLI errors (#3431) Co-authored-by: codex --- .../src/textGeneration/TextGenerationPrompts.test.ts | 12 ++++++++++++ .../server/src/textGeneration/TextGenerationUtils.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 1435bc522b8..b67e8b93c4a 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -190,4 +190,16 @@ describe("normalizeCliError", () => { expect(result).toBeInstanceOf(TextGenerationError); expect(result.detail).toBe("fallback"); }); + + it("does not expose CLI failure details in the public error message", () => { + const result = normalizeCliError( + "codex", + "generateCommitMessage", + new Error("request failed with access_token=secret-token"), + "Failed to generate a commit message", + ); + + expect(result.detail).toBe("Failed to generate a commit message"); + expect(result.message).not.toContain("secret-token"); + }); }); diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index a786f81b2c8..ad2911c20f7 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -99,7 +99,7 @@ export function normalizeCliError( } return new TextGenerationError({ operation, - detail: `${fallback}: ${error.message}`, + detail: fallback, cause: error, }); } From eb5eb0d9fa99364aa3b5091c62d6a187f5eef375 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:58:10 -0700 Subject: [PATCH 174/257] [codex] Structure managed endpoint allocation failures (#3421) Co-authored-by: codex --- .../ManagedEndpointAllocations.ts | 2 +- .../ManagedEndpointProvider.test.ts | 60 ++++- .../environments/ManagedEndpointProvider.ts | 245 +++++++++--------- 3 files changed, 177 insertions(+), 130 deletions(-) diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index c951ee03c8d..f6cefa69071 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -120,7 +120,7 @@ const whereAllocation = (input: ManagedEndpointAllocationKey) => eq(relayManagedEndpointAllocations.environmentId, input.environmentId), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return ManagedEndpointAllocations.of({ diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 479be412380..56bf6319d0d 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -133,7 +133,7 @@ function makeDnsClient( operation: "update-record", hostname: request.name, dnsRecordId, - cause: `DNS record ${dnsRecordId} does not exist.`, + cause: { _tag: "NotFound", dnsRecordId }, }); } }), @@ -531,6 +531,59 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); + it.effect("does not hide non-not-found checkpoint update failures", () => { + const dnsCalls: DnsCall[] = []; + const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "update-record", + dnsRecordId: "created-record-id", + cause: new Error("Cloudflare DNS unavailable"), + }); + let records: ReadonlyArray<{ readonly id: string }> = []; + const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + listRecords: (hostname) => + Effect.sync(() => { + dnsCalls.push({ operation: "listRecords", input: hostname }); + return records; + }), + createRecord: (request) => + Effect.sync(() => { + dnsCalls.push({ operation: "createRecord", input: request }); + const record = { id: "created-record-id" }; + records = [record]; + return record; + }), + updateRecord: (dnsRecordId, request) => + Effect.sync(() => { + dnsCalls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); + }).pipe(Effect.andThen(Effect.fail(failure))), + deleteRecord: () => Effect.void, + }); + const layer = providerLayer(makePersistentTunnelClient(), dnsClient, makeAllocations()); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const request = { + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + } as const; + yield* provider.provision(request); + const error = yield* Effect.flip(provider.provision(request)); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "ensure-dns-record", + userId: "user_ABC", + environmentId: "env_ABC", + }); + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listRecords", + "createRecord", + "updateRecord", + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect( "deprovisions checkpointed DNS and tunnel resources before removing the allocation", () => { @@ -765,11 +818,11 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); }); - it.effect("reports malformed tunnel responses without manufacturing a cause", () => { + it.effect("reports mismatched tunnel responses without manufacturing a cause", () => { const dnsCalls: DnsCall[] = []; const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ ...makeTunnelClient(), - create: () => Effect.succeed({ id: "returned-tunnel-id", name: null }), + create: () => Effect.succeed({ id: "returned-tunnel-id", name: "unexpected-tunnel" }), }); return Effect.gen(function* () { @@ -790,6 +843,7 @@ describe("ManagedEndpointProvider", () => { hostname: expectedManagedHostname("env_ABC"), tunnelName: expectedManagedTunnelName("env_ABC"), returnedTunnelId: "returned-tunnel-id", + returnedTunnelName: "unexpected-tunnel", }); if (error._tag === "ManagedEndpointProvisioningFailed") { expect(error.cause).toBeUndefined(); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index bb2dd4b0ce9..68e93f8b17c 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -192,11 +192,8 @@ export class ManagedEndpointTunnelClient extends Context.Service< } >()("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)); + Layer.succeed(ManagedEndpointTunnelClient, client); interface ManagedEndpointCnameRecordInput { readonly type: "CNAME"; @@ -247,11 +244,8 @@ export class ManagedEndpointDnsClient extends Context.Service< } >()("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)); + Layer.succeed(ManagedEndpointDnsClient, client); const requireCloudflareSettings = Effect.fnUntraced(function* ( settings: RelayConfiguration.RelayConfiguration["Service"], @@ -331,7 +325,7 @@ const ignoreNotFound = ( }), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; const crypto = yield* Crypto.Crypto; const tunnels = yield* ManagedEndpointTunnelClient; @@ -366,7 +360,10 @@ const make = Effect.gen(function* () { .updateRecord(preferredDnsRecordId, dnsRecord) .pipe( Effect.as(true), - Effect.orElseSucceed(() => false), + Effect.catchTags({ + ManagedEndpointDnsClientError: (error) => + isNotFoundCause(error.cause) ? Effect.succeed(false) : Effect.fail(error), + }), ); if (checkpointedRecordUpdated) { return preferredDnsRecordId; @@ -550,7 +547,7 @@ const make = Effect.gen(function* () { }), ), ); - if (!tunnelResponse.id || !tunnelResponse.name) { + if (!tunnelResponse.id || tunnelResponse.name !== tunnelName) { return yield* new ManagedEndpointProvisioningFailed({ userId: input.userId, environmentId: input.environmentId, @@ -712,131 +709,127 @@ export const layerCloudflareBindings = ( layer.pipe( Layer.provide( Layer.mergeAll( - layerTunnelClient( - ManagedEndpointTunnelClient.of({ - list: (request) => - tunnelClient.list(request).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "list", - tunnelName: request.name, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + layerTunnelClient({ + list: (request) => + tunnelClient.list(request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "list", + tunnelName: request.name, + cause, + }), ), - create: (request) => - tunnelClient.create(request).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "create", - tunnelName: request.name, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + create: (request) => + tunnelClient.create(request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "create", + tunnelName: request.name, + cause, + }), ), - putConfiguration: (tunnelId, config) => - tunnelClient.putConfiguration(tunnelId, config).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "put-configuration", - tunnelId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + putConfiguration: (tunnelId, config) => + tunnelClient.putConfiguration(tunnelId, config).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "put-configuration", + tunnelId, + cause, + }), ), - getToken: (tunnelId) => - tunnelClient.getToken(tunnelId).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "get-token", - tunnelId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + getToken: (tunnelId) => + tunnelClient.getToken(tunnelId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "get-token", + tunnelId, + cause, + }), ), - delete: (tunnelId) => - tunnelClient.delete(tunnelId).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "delete", - tunnelId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + delete: (tunnelId) => + tunnelClient.delete(tunnelId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId, + cause, + }), ), - }), - ), - layerDnsClient( - ManagedEndpointDnsClient.of({ - listRecords: (hostname) => - dnsClient.listDnsRecords({ search: hostname }).pipe( - Effect.map((response) => - response.result.filter( - (record): record is typeof record & { readonly id: string } => - typeof record.id === "string" && - normalizeHostname(record.name) === normalizeHostname(hostname), - ), - ), - Effect.mapError( - (cause) => - new ManagedEndpointDnsClientError({ - operation: "list-records", - hostname, - cause, - }), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), + layerDnsClient({ + listRecords: (hostname) => + dnsClient.listDnsRecords({ search: hostname }).pipe( + Effect.map((response) => + response.result.filter( + (record): record is typeof record & { readonly id: string } => + typeof record.id === "string" && + normalizeHostname(record.name) === normalizeHostname(hostname), ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), - createRecord: (request) => - dnsClient.createDnsRecord(request).pipe( - Effect.map((response) => ({ id: response.id })), - Effect.mapError( - (cause) => - new ManagedEndpointDnsClientError({ - operation: "create-record", - hostname: request.name, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "list-records", + hostname, + cause, + }), ), - updateRecord: (dnsRecordId, request) => - dnsClient.updateDnsRecord(dnsRecordId, request).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointDnsClientError({ - operation: "update-record", - hostname: request.name, - dnsRecordId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + createRecord: (request) => + dnsClient.createDnsRecord(request).pipe( + Effect.map((response) => ({ id: response.id })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "create-record", + hostname: request.name, + cause, + }), ), - deleteRecord: (dnsRecordId) => - dnsClient.deleteDnsRecord(dnsRecordId).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointDnsClientError({ - operation: "delete-record", - dnsRecordId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + updateRecord: (dnsRecordId, request) => + dnsClient.updateDnsRecord(dnsRecordId, request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "update-record", + hostname: request.name, + dnsRecordId, + cause, + }), ), - }), - ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + deleteRecord: (dnsRecordId) => + dnsClient.deleteDnsRecord(dnsRecordId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "delete-record", + dnsRecordId, + cause, + }), + ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), ), ), ); From be56fa43b0aaad6ab537526e2129501038bbb6a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:58:59 -0700 Subject: [PATCH 175/257] [codex] Preserve workspace root stat failures (#3278) Co-authored-by: codex --- apps/server/src/server.test.ts | 34 +++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 1 + .../src/workspace/WorkspacePaths.test.ts | 74 +++++++++++++++++++ apps/server/src/workspace/WorkspacePaths.ts | 59 +++++++++++++-- apps/server/src/ws.ts | 6 ++ packages/contracts/src/project.ts | 1 + 6 files changed, 168 insertions(+), 7 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 32a7cc17944..e1daf20ed57 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { AuthAccessTokenType, @@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reports workspace root stat failures without relabeling them as missing", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const blockedRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-stat-error-", + }); + const workspaceRoot = path.join(blockedRoot, "workspace"); + yield* fs.makeDirectory(workspaceRoot); + yield* fs.chmod(blockedRoot, 0o000); + + const result = yield* Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + return yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsListEntries]({ cwd: workspaceRoot }).pipe(Effect.result), + ), + ); + }).pipe(Effect.ensuring(fs.chmod(blockedRoot, 0o700).pipe(Effect.ignore))); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectListEntriesError") { + assert.fail("Expected a ProjectListEntriesError"); + } + const error = result.failure; + assert.equal(error.failure, "workspace_root_stat_failed"); + assert.equal(error.normalizedCwd, workspaceRoot); + assert.equal(error.detail, "validate-existing"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index cdb26a38bc7..81fb735ea2e 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ export const WorkspaceEntriesError = Schema.Union([ WorkspacePaths.WorkspaceRootNotExistsError, WorkspacePaths.WorkspaceRootCreateFailedError, + WorkspacePaths.WorkspaceRootStatFailedError, WorkspacePaths.WorkspaceRootNotDirectoryError, WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index ecce54b67d6..4f3bc833b4c 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.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 Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { expect(error.message).toContain("Workspace root is not a directory:"); }), ); + + it.effect("preserves non-NotFound stat failures while validating the root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + stat: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: String(path), + description: "Test PermissionDenied stat failure.", + }), + ), + }), + ); + const path = yield* Path.Path; + const workspaceRoot = " ./permission-denied "; + const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); + + const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); + expect(error).toMatchObject({ + workspaceRoot, + normalizedWorkspaceRoot, + phase: "validate-existing", + }); + }), + ); + + it.effect("preserves stat failures while verifying a newly created root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + let statCalls = 0; + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + stat: (path) => { + statCalls += 1; + const reason = statCalls === 1 ? "NotFound" : "PermissionDenied"; + return Effect.fail( + PlatformError.systemError({ + _tag: reason, + module: "FileSystem", + method: "stat", + pathOrDescriptor: String(path), + description: `Test ${reason} stat failure.`, + }), + ); + }, + makeDirectory: () => Effect.void, + }), + ); + const path = yield* Path.Path; + const workspaceRoot = " ./created-then-unreadable "; + const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); + + const error = yield* workspacePaths + .normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); + expect(error).toMatchObject({ + workspaceRoot, + normalizedWorkspaceRoot, + phase: "verify-created", + }); + }), + ); }); describe("resolveRelativePathWithinRoot", () => { diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 85e3db561c4..5acf6677cde 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()( + "WorkspaceRootStatFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + phase: Schema.Literals(["validate-existing", "verify-created"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`; + } +} + export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( "WorkspaceRootNotDirectoryError", { @@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass Effect.Effect< string, - WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError + | WorkspaceRootNotExistsError + | WorkspaceRootCreateFailedError + | WorkspaceRootStatFailedError + | WorkspaceRootNotDirectoryError >; /** * Resolve a relative path within a validated workspace root. @@ -117,13 +135,38 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* ( + workspaceRoot: string, + normalizedWorkspaceRoot: string, + phase: WorkspaceRootStatFailedError["phase"], + ) { + return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new WorkspaceRootStatFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + phase, + cause, + }), + ), + onSuccess: Effect.succeed, + }), + ); + }); + const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - let workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); + let workspaceStat = yield* statWorkspaceRoot( + workspaceRoot, + normalizedWorkspaceRoot, + "validate-existing", + ); if (!workspaceStat && options?.createIfMissing) { yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( Effect.mapError( @@ -135,9 +178,11 @@ export const make = Effect.gen(function* () { }), ), ); - workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); + workspaceStat = yield* statWorkspaceRoot( + workspaceRoot, + normalizedWorkspaceRoot, + "verify-created", + ); } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7c45d0b58b8..05e78de476c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr failure: "workspace_root_create_failed", normalizedCwd: error.normalizedWorkspaceRoot, }; + case "WorkspaceRootStatFailedError": + return { + failure: "workspace_root_stat_failed", + normalizedCwd: error.normalizedWorkspaceRoot, + detail: error.phase, + }; case "WorkspaceRootNotDirectoryError": return { failure: "workspace_root_not_directory", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 338b87096d9..d59b9770ad3 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; export const ProjectEntriesFailure = Schema.Literals([ "workspace_root_not_found", "workspace_root_create_failed", + "workspace_root_stat_failed", "workspace_root_not_directory", "search_index_create_failed", "search_index_scan_timed_out", From 6e9b43cb84c1b93c348d76880a0bf0083f395e47 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:11:52 -0700 Subject: [PATCH 176/257] Preserve trace file read causes (#3300) Co-authored-by: codex --- .../src/diagnostics/TraceDiagnostics.test.ts | 47 ++++++++++--- .../src/diagnostics/TraceDiagnostics.ts | 68 +++++++++++++------ 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index d4ffa4a5fc2..70bb4dc815c 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -3,8 +3,10 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; import * as TraceDiagnostics from "./TraceDiagnostics.ts"; @@ -187,18 +189,17 @@ describe("TraceDiagnostics", () => { it.effect("keeps loaded trace data when one rotated trace file fails to read", () => Effect.gen(function* () { const traceFilePath = "/tmp/server.trace.ndjson"; + const readFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: `${traceFilePath}.1`, + }); const fileSystemLayer = FileSystem.layerNoop({ readFileString: (path) => path === `${traceFilePath}.1` - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - description: "permission denied", - pathOrDescriptor: path, - }), - ) + ? Effect.fail(readFailure) : Effect.succeed( record({ name: "server.getConfig", @@ -209,20 +210,44 @@ describe("TraceDiagnostics", () => { }), ), }); + const logAnnotations: Array> = []; + const logger = Logger.make((options) => { + logAnnotations.push({ ...options.fiber.getRef(References.CurrentLogAnnotations) }); + }); const diagnostics = yield* TraceDiagnostics.readTraceDiagnostics({ traceFilePath, maxFiles: 1, readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - }).pipe(Effect.provide(TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)))); + }).pipe( + Effect.provide( + Layer.mergeAll( + TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); assert.equal(diagnostics.recordCount, 1); assert.equal( Option.getOrElse(diagnostics.partialFailure, () => false), true, ); - assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-read-failed"); + assert.deepStrictEqual(Option.getOrUndefined(diagnostics.error), { + kind: "trace-file-read-failed", + message: `Failed to read local trace file '${traceFilePath}.1'.`, + }); assert.deepStrictEqual(diagnostics.scannedFilePaths, [`${traceFilePath}.1`, traceFilePath]); + + const failureLog = logAnnotations.find( + (annotations) => annotations.traceFilePath === `${traceFilePath}.1`, + ); + assert.exists(failureLog); + assert.deepStrictEqual(failureLog, { + traceFilePath: `${traceFilePath}.1`, + errorTag: "TraceFileReadError", + causeTag: "PermissionDenied", + }); }), ); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index d396f4e4ee9..d54e033380c 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -14,6 +14,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; interface TraceRecordLike { readonly name?: unknown; @@ -39,6 +41,19 @@ export interface TraceDiagnosticsOptions { readonly readAt?: DateTime.Utc; } +export class TraceFileReadError extends Schema.TaggedErrorClass()( + "TraceFileReadError", + { + traceFilePath: Schema.String, + causeTag: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read local trace file '${this.traceFilePath}'.`; + } +} + export class TraceDiagnostics extends Context.Service< TraceDiagnostics, { @@ -153,10 +168,6 @@ function isNotFoundError(error: PlatformError.PlatformError): boolean { return error.reason._tag === "NotFound"; } -function platformErrorMessage(error: PlatformError.PlatformError): string { - return error.message || String(error); -} - function insertBoundedSlowestSpan( slowestSpans: ServerTraceDiagnosticsSpanOccurrence[], span: ServerTraceDiagnosticsSpanOccurrence, @@ -377,22 +388,26 @@ export function aggregateTraceDiagnostics( type TraceFileReadResult = | { readonly _tag: "Loaded"; readonly path: string; readonly text: string } - | { readonly _tag: "Missing"; readonly path: string } - | { readonly _tag: "Failed"; readonly path: string; readonly message: string }; + | { readonly _tag: "Missing"; readonly path: string }; function readTraceFile( fileSystem: FileSystem.FileSystem, path: string, -): Effect.Effect { +): Effect.Effect { return fileSystem.readFileString(path).pipe( - Effect.map((text) => ({ _tag: "Loaded" as const, path, text })), - Effect.catch((error: PlatformError.PlatformError) => - Effect.succeed( - isNotFoundError(error) - ? { _tag: "Missing" as const, path } - : { _tag: "Failed" as const, path, message: platformErrorMessage(error) }, - ), - ), + Effect.map((text): TraceFileReadResult => ({ _tag: "Loaded", path, text })), + Effect.catchTags({ + PlatformError: (cause) => + isNotFoundError(cause) + ? Effect.succeed({ _tag: "Missing", path }) + : Effect.fail( + new TraceFileReadError({ + traceFilePath: path, + causeTag: cause.reason._tag, + cause, + }), + ), + }), ); } @@ -405,19 +420,34 @@ export const make = Effect.gen(function* () { const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); const results = yield* Effect.all( - paths.map((path) => readTraceFile(fileSystem, path)), + paths.map((path) => + readTraceFile(fileSystem, path).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to read local trace file.").pipe( + Effect.annotateLogs({ + traceFilePath: cause.traceFilePath, + errorTag: cause._tag, + causeTag: cause.causeTag, + }), + ), + ), + Effect.result, + ), + ), { concurrency: 1, }, ); const files = results.flatMap((result) => - result._tag === "Loaded" ? [{ path: result.path, text: result.text }] : [], + Result.isSuccess(result) && result.success._tag === "Loaded" + ? [{ path: result.success.path, text: result.success.text }] + : [], ); - const readFailure = results.find((result) => result._tag === "Failed"); + const readFailure = results.find(Result.isFailure); const readFailureError = readFailure ? ({ kind: "trace-file-read-failed", - message: readFailure.message.trim() || `Failed to read ${readFailure.path}.`, + message: readFailure.failure.message, } satisfies TraceDiagnosticsErrorSummary) : undefined; From c637dfc454342c156dbe61ee8513f5e0ac0c4691 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:16:32 -0700 Subject: [PATCH 177/257] [codex] enrich Git workflow errors (#3241) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 14 +- apps/server/src/git/GitManager.ts | 143 +++++++++++------- .../server/src/git/GitWorkflowService.test.ts | 59 +++++++- apps/server/src/git/GitWorkflowService.ts | 114 +++++++------- .../src/vcs/VcsStatusBroadcaster.test.ts | 64 +++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 95 +++++++++++- packages/contracts/src/git.ts | 1 + 7 files changed, 359 insertions(+), 131 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 14490765a9d..bb7f91ffc39 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1589,16 +1589,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* initRepo(repoDir); const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); + }).pipe(Effect.flip); - expect(errorMessage).toContain("no changes to commit"); + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "runFeatureBranchStep", + cwd: repoDir, + }); + expect(error.message).toContain("no changes to commit"); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 88eb0e21282..46da2e6c1f9 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -320,14 +320,6 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { }; } -function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { - return new GitManagerError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; @@ -535,17 +527,27 @@ export const make = Effect.gen(function* () { const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => - gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause), - ), - ); + const randomUUIDv4 = (cwd: string) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "randomUUIDv4", + cwd, + detail: "Failed to generate Git operation identifier.", + cause, + }), + ), + ); const createProgressEmitter = ( input: { cwd: string; action: GitStackedAction }, options?: GitRunStackedActionOptions, ) => - (options?.actionId === undefined ? randomUUIDv4 : Effect.succeed(options.actionId)).pipe( + (options?.actionId === undefined + ? randomUUIDv4(input.cwd) + : Effect.succeed(options.actionId) + ).pipe( Effect.map((actionId) => { const reporter = options?.progressReporter; const emit = (event: GitActionProgressPayload) => @@ -1284,16 +1286,18 @@ export const make = Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { - return yield* gitManagerError( - "runPrStep", - "Cannot create a pull request from detached HEAD.", - ); + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); } if (!details.hasUpstream) { - return yield* gitManagerError( - "runPrStep", - "Current branch has not been pushed. Push before creating a PR.", - ); + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Current branch has not been pushed. Push before creating a PR.", + }); } const headContext = yield* resolveBranchHeadContext(cwd, { @@ -1332,14 +1336,21 @@ export const make = Effect.gen(function* () { modelSelection, }); - const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${yield* randomUUIDv4}.md`); - yield* fileSystem - .writeFileString(bodyFile, generated.body) - .pipe( - Effect.mapError((cause) => - gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), - ), - ); + const bodyFile = path.join( + tempDir, + `t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`, + ); + yield* fileSystem.writeFileString(bodyFile, generated.body).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Failed to write pull request body temp file.", + cause, + }), + ), + ); yield* emit({ kind: "phase_started", phase: "pr", @@ -1541,10 +1552,12 @@ export const make = Effect.gen(function* () { }; } if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); } yield* materializePullRequestHeadBranch( @@ -1569,10 +1582,12 @@ export const make = Effect.gen(function* () { }; } if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); } const worktree = yield* gitCore.createWorktree({ @@ -1607,10 +1622,11 @@ export const make = Effect.gen(function* () { modelSelection, }); if (!suggestion) { - return yield* gitManagerError( - "runFeatureBranchStep", - "Cannot create a feature branch because there are no changes to commit.", - ); + return yield* new GitManagerError({ + operation: "runFeatureBranchStep", + cwd, + detail: "Cannot create a feature branch because there are no changes to commit.", + }); } const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); @@ -1647,16 +1663,18 @@ export const make = Effect.gen(function* () { const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; if (input.featureBranch && !wantsCommit) { - return yield* gitManagerError( - "runStackedAction", - "Feature-branch checkout is only supported for commit actions.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Feature-branch checkout is only supported for commit actions.", + }); } if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit local changes before creating a PR.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Commit local changes before creating a PR.", + }); } const phases: GitActionProgressPhase[] = [ @@ -1672,13 +1690,18 @@ export const make = Effect.gen(function* () { }); if (!input.featureBranch && wantsPush && !initialStatus.branch) { - return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot push from detached HEAD.", + }); } if (!input.featureBranch && wantsPr && !initialStatus.branch) { - return yield* gitManagerError( - "runStackedAction", - "Cannot create a pull request from detached HEAD.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); } let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; @@ -1687,8 +1710,14 @@ export const make = Effect.gen(function* () { const modelSelection = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.textGenerationModelSelection), - Effect.mapError((cause) => - gitManagerError("runStackedAction", "Failed to get server settings.", cause), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Failed to get server settings.", + cause, + }), ), ); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 03cd624600d..2ea14b951fe 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,7 +1,9 @@ -import { assert, describe, it, vi } from "@effect/vitest"; +import { assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; + import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -132,4 +134,59 @@ describe("GitWorkflowService", () => { ), ), ); + + it.effect("structures workflow detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "GitWorkflowService.status", + cwd: "/repo", + detail: "Failed to detect a VCS repository for this Git workflow.", + }); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }); + + it.effect("structures command detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream command detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitCommandError", + operation: "GitWorkflowService.listRefs", + command: "vcs-route", + cwd: "/repo", + detail: "Failed to detect a VCS repository for this Git command.", + }); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index f958b663006..100b9beadba 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -94,20 +94,6 @@ export class GitWorkflowService extends Context.Service< } >()("t3/git/GitWorkflowService") {} -const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => - new GitManagerError({ - operation, - detail: `${detail} (${cwd})`, - }); - -const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => - new GitCommandError({ - operation, - command: "vcs-route", - cwd, - detail, - }); - function nonRepositoryLocalStatus(): VcsStatusLocalResult { return { isRepo: false, @@ -153,23 +139,23 @@ export const make = Effect.gen(function* () { operation: string, cwd: string, ) { - const handle = yield* registry - .resolve({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ operation, cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to resolve the VCS driver for this Git workflow.", + cause, + }), + ), + ); if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( + return yield* new GitManagerError({ operation, cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, + }); } }); @@ -177,48 +163,50 @@ export const make = Effect.gen(function* () { operation: string, cwd: string, ) { - const handle = yield* registry - .resolve({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitCommand( + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ operation, + command: "vcs-route", cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to resolve the VCS driver for this Git command.", + cause, + }), + ), + ); if (handle.kind !== "git") { - return yield* unsupportedGitCommand( + return yield* new GitCommandError({ operation, + command: "vcs-route", cwd, - `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + }); } }); const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ operation, cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, + }), + ), + ); if (!handle) { return false; } if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( + return yield* new GitManagerError({ operation, cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, + }); } return true; }, @@ -227,26 +215,28 @@ export const make = Effect.gen(function* () { const detectGitRepositoryForCommand = Effect.fn( "GitWorkflowService.detectGitRepositoryForCommand", )(function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitCommand( + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ operation, + command: "vcs-route", cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to detect a VCS repository for this Git command.", + cause, + }), + ), + ); if (!handle) { return false; } if (handle.kind !== "git") { - return yield* unsupportedGitCommand( + return yield* new GitCommandError({ operation, + command: "vcs-route", cwd, - `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + }); } return true; }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index c14115e7119..032e48e4612 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,11 +1,13 @@ import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; @@ -190,6 +192,7 @@ describe("VcsStatusBroadcaster", () => { ? Effect.fail( new GitManagerError({ operation: "VcsStatusBroadcaster.test", + cwd: "/repo", detail: "remote status failed", }), ) @@ -431,6 +434,12 @@ describe("VcsStatusBroadcaster", () => { remoteInvalidationCalls: 0, remoteStatusRefreshUpstreamValues: [] as Array, }; + const privateCwd = "/private/user/workspace/repo"; + const nestedCause = new Error("private nested VCS failure"); + const messages: Array> = []; + const logger = Logger.make(({ message }) => { + messages.push(message as ReadonlyArray); + }); let firstRemoteAttemptDeferred: Deferred.Deferred | null = null; const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -449,7 +458,9 @@ describe("VcsStatusBroadcaster", () => { return Effect.fail( new GitManagerError({ operation: "VcsStatusBroadcaster.test", - detail: "initial remote status failed", + cwd: privateCwd, + detail: "private initial remote status failure", + cause: nestedCause, }), ).pipe( Effect.ensuring( @@ -480,7 +491,7 @@ describe("VcsStatusBroadcaster", () => { const remoteUpdatedDeferred = yield* Deferred.make(); yield* Stream.runForEach( broadcaster.streamStatus( - { cwd: "/repo" }, + { cwd: privateCwd }, { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, ), (event) => @@ -492,6 +503,24 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(firstRemoteAttemptDeferred); yield* Effect.yieldNow; assert.equal(state.remoteStatusCalls, 1); + assert.deepStrictEqual( + messages.find((message) => message[0] === "VCS remote status refresh failed"), + [ + "VCS remote status refresh failed", + { + cwdLength: privateCwd.length, + reasonCount: 1, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.test"], + defectCount: 0, + defectTags: [], + interruptionCount: 0, + consecutiveFailures: 1, + nextDelayMs: 30_000, + }, + ], + ); yield* TestClock.adjust(Duration.seconds(30)); const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); @@ -505,7 +534,15 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(testLayer, TestClock.layer()))); + }).pipe( + Effect.provide( + Layer.mergeAll( + testLayer, + TestClock.layer(), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); }); it.effect("delays automatic refresh when a cached remote snapshot is available", () => { @@ -573,6 +610,27 @@ describe("VcsStatusBroadcaster", () => { ); }); + it("summarizes refresh causes without exposing nested failure details", () => { + const nestedCause = new Error("private nested failure detail"); + const failure = new GitManagerError({ + operation: "VcsStatusBroadcaster.remoteStatus", + cwd: "/private/user/workspace/repo", + detail: "private Git failure detail", + cause: nestedCause, + }); + const cause = Cause.combine(Cause.fail(failure), Cause.die(new TypeError("private defect"))); + + assert.deepStrictEqual(VcsStatusBroadcaster.remoteRefreshFailureDiagnostics(cause), { + reasonCount: 2, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.remoteStatus"], + defectCount: 1, + defectTags: ["TypeError"], + interruptionCount: 0, + }); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 860fc8075b3..c238154f58c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -26,6 +27,91 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); +const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; +const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; + +function boundedDiagnosticValue(value: string): string { + return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); +} + +function diagnosticValueTag(value: unknown): string { + try { + if ( + typeof value === "object" && + value !== null && + "_tag" in value && + typeof value._tag === "string" + ) { + return boundedDiagnosticValue(value._tag); + } + if (value instanceof Error) { + return boundedDiagnosticValue(value.name); + } + return typeof value; + } catch { + return "Uninspectable"; + } +} + +function diagnosticFailureOperation(value: unknown): string | undefined { + try { + if ( + typeof value === "object" && + value !== null && + "operation" in value && + typeof value.operation === "string" + ) { + return boundedDiagnosticValue(value.operation); + } + } catch { + return undefined; + } + return undefined; +} + +function addUniqueDiagnosticValue(values: Array, value: string | undefined): void { + if ( + value !== undefined && + values.length < MAX_FAILURE_DIAGNOSTIC_VALUES && + !values.includes(value) + ) { + values.push(value); + } +} + +export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { + const failureTags: Array = []; + const failureOperations: Array = []; + const defectTags: Array = []; + let failureCount = 0; + let defectCount = 0; + let interruptionCount = 0; + + for (const reason of cause.reasons) { + if (Cause.isFailReason(reason)) { + failureCount += 1; + addUniqueDiagnosticValue(failureTags, diagnosticValueTag(reason.error)); + addUniqueDiagnosticValue(failureOperations, diagnosticFailureOperation(reason.error)); + continue; + } + if (Cause.isDieReason(reason)) { + defectCount += 1; + addUniqueDiagnosticValue(defectTags, diagnosticValueTag(reason.defect)); + continue; + } + interruptionCount += 1; + } + + return { + reasonCount: cause.reasons.length, + failureCount, + failureTags, + failureOperations, + defectCount, + defectTags, + interruptionCount, + }; +} interface VcsStatusChange { readonly cwd: string; @@ -318,14 +404,19 @@ export const make = Effect.gen(function* () { return activeInterval; } + const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); + if (interruptionReasons.length > 0) { + return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); + } + 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(), + cwdLength: cwd.length, + ...remoteRefreshFailureDiagnostics(exit.cause), consecutiveFailures, nextDelayMs: Duration.toMillis(nextDelay), }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 38aba54277c..0f1f09729be 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -352,6 +352,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass()("GitManagerError", { operation: Schema.String, + cwd: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { From fc2cdeceb9ddb28c0de834b9fcb38abf68d99148 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:50:19 -0700 Subject: [PATCH 178/257] [codex] Structure preview automation boundary failures (#3436) Co-authored-by: codex --- .../preview/PreviewAutomationOwner.tsx | 399 +++++++----------- .../preview/previewAutomationErrors.ts | 169 ++++++++ .../previewAutomationRequestConsumer.test.ts | 139 +++++- .../previewAutomationRequestConsumer.ts | 68 +-- 4 files changed, 470 insertions(+), 305 deletions(-) create mode 100644 apps/web/src/components/preview/previewAutomationErrors.ts diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index e3d08ea131b..2be14363624 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -3,19 +3,13 @@ import { useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { - EnvironmentId, type PreviewAutomationNavigateInput, type PreviewAutomationOpenInput, - PreviewAutomationOperation, type PreviewAutomationOwner as PreviewAutomationOwnerState, type PreviewAutomationRequest, type PreviewAutomationStatus, - PreviewTabId, type ScopedThreadRef, - ThreadId, - TrimmedNonEmptyString, } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; import { @@ -31,105 +25,19 @@ import { useEnvironmentConnectionState } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +import { + PreviewAutomationNavigationTimeoutError, + PreviewAutomationOperationError, + PreviewAutomationOverlayTimeoutError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationStaleOwnerError, + PreviewAutomationTargetUnavailableError, +} from "./previewAutomationErrors"; import { createLatestPreviewAutomationRequestHandler, createPreviewAutomationRequestConsumerAtom, } from "./previewAutomationRequestConsumer"; -export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationOverlayTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError"; - } - - override get message(): string { - return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; - } -} - -export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationNavigationTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: PreviewTabId, - readiness: Schema.Literals(["domContentLoaded", "load"]), - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError"; - } - - override get message(): string { - return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; - } -} - -export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( - "PreviewAutomationStaleOwnerError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - expectedThreadId: ThreadId, - requestedThreadId: ThreadId, - }, -) { - get responseTag() { - return "PreviewAutomationUnavailableError"; - } - - override get message(): string { - return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; - } -} - -export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( - "PreviewAutomationTargetUnavailableError", - { - requestId: TrimmedNonEmptyString, - operation: PreviewAutomationOperation, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: Schema.NullOr(PreviewTabId), - bridgeAvailable: Schema.Boolean, - }, -) { - get responseTag() { - return "PreviewAutomationTabNotFoundError"; - } - - override get message(): string { - return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; - } -} - -export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( - "PreviewAutomationRecordingNotActiveError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: PreviewTabId, - }, -) { - get responseTag() { - return "PreviewAutomationExecutionError"; - } - - override get message(): string { - return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; - } -} - export function observeAutomationOwnerConnectedGeneration( previousGeneration: number | null, connectedGeneration: number | null, @@ -287,152 +195,166 @@ export function PreviewAutomationOwner(props: { const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { - if (request.threadId !== threadRef.threadId) { - throw new PreviewAutomationStaleOwnerError({ + let tabId = request.tabId ?? null; + try { + if (request.threadId !== threadRef.threadId) { + throw new PreviewAutomationStaleOwnerError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + expectedThreadId: threadRef.threadId, + requestedThreadId: request.threadId, + }); + } + const state = readThreadPreviewState(threadRef); + tabId = request.tabId ?? state.snapshot?.tabId ?? null; + const unavailableTarget = { requestId: request.requestId, + operation: request.operation, environmentId: threadRef.environmentId, - expectedThreadId: threadRef.threadId, - requestedThreadId: request.threadId, - }); - } - const state = readThreadPreviewState(threadRef); - const tabId = request.tabId ?? state.snapshot?.tabId ?? null; - const unavailableTarget = { - requestId: request.requestId, - operation: request.operation, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - bridgeAvailable: Boolean(previewBridge), - }; - switch (request.operation) { - case "status": - return currentStatus(threadRef, visible); - case "open": { - const input = request.input as PreviewAutomationOpenInput; - let activeTabId = - (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; - if (!activeTabId) { - const result = await open({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - ...(input.url ? { url: input.url } : {}), - }, - }); - if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); + threadId: threadRef.threadId, + tabId, + bridgeAvailable: Boolean(previewBridge), + }; + switch (request.operation) { + case "status": + return await currentStatus(threadRef, visible); + case "open": { + const input = request.input as PreviewAutomationOpenInput; + let activeTabId = + (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + tabId = activeTabId; + if (!activeTabId) { + const result = await open({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + ...(input.url ? { url: input.url } : {}), + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const snapshot = result.value; + applyPreviewServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + tabId = activeTabId; + } else if (input.url && previewBridge) { + await previewBridge.navigate(activeTabId, input.url); } - const snapshot = result.value; - applyPreviewServerSnapshot(threadRef, snapshot); - activeTabId = snapshot.tabId; - } else if (input.url && previewBridge) { - await previewBridge.navigate(activeTabId, input.url); - } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); - } - await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); - return currentStatus(threadRef, input.show ?? true); - } - case "navigate": { - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( - threadRef.environmentId, - input.target ?? { kind: "url", url: input.url! }, - ); - await previewBridge.navigate(tabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - tabId, - input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, - ); - return currentStatus(threadRef, visible); - } - case "snapshot": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.snapshot(tabId); - case "click": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.click( - tabId, - request.input as Parameters[1], - ); - case "type": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.type( - tabId, - request.input as Parameters[1], - ); - case "press": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.press( - tabId, - request.input as Parameters[1], - ); - case "scroll": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.scroll( - tabId, - request.input as Parameters[1], - ); - case "evaluate": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.evaluate( - tabId, - request.input as Parameters[1], - ); - case "waitFor": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.waitFor( - tabId, - request.input as Parameters[1], - ); - case "recordingStart": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } + await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); + return await currentStatus(threadRef, input.show ?? true); } - const startedAt = await startBrowserRecording(tabId); - return { - tabId, - recording: true, - startedAt, - }; - } - case "recordingStop": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + case "navigate": { + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const input = request.input as PreviewAutomationNavigateInput; + const resolution = resolveBrowserNavigationTarget( + threadRef.environmentId, + input.target ?? { kind: "url", url: input.url! }, + ); + await previewBridge.navigate(tabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + threadRef, + request.requestId, + tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return await currentStatus(threadRef, visible); } - const artifact = await stopBrowserRecording(tabId); - if (!artifact) { - throw new PreviewAutomationRecordingNotActiveError({ - requestId: request.requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, + case "snapshot": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.snapshot(tabId); + case "click": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.click( + tabId, + request.input as Parameters[1], + ); + case "type": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.type( + tabId, + request.input as Parameters[1], + ); + case "press": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.press( + tabId, + request.input as Parameters[1], + ); + case "scroll": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.scroll( + tabId, + request.input as Parameters[1], + ); + case "evaluate": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.evaluate( + tabId, + request.input as Parameters[1], + ); + case "waitFor": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.waitFor( + tabId, + request.input as Parameters[1], + ); + case "recordingStart": { + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const startedAt = await startBrowserRecording(tabId); + return { tabId, - }); + recording: true, + startedAt, + }; + } + case "recordingStop": { + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const artifact = await stopBrowserRecording(tabId); + if (!artifact) { + throw new PreviewAutomationRecordingNotActiveError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + }); + } + return artifact; } - return artifact; } + } catch (cause) { + throw PreviewAutomationOperationError.fromCause({ + requestId: request.requestId, + operation: request.operation, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + cause, + }); } }, [open, threadRef, visible], @@ -448,6 +370,7 @@ export function PreviewAutomationOwner(props: { () => createPreviewAutomationRequestConsumerAtom({ requestsAtom: automationRequestsAtom, + environmentId: threadRef.environmentId, handleRequest: requestHandler.handle, respond: (response) => respondToAutomation({ diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts new file mode 100644 index 00000000000..c4ca445458c --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -0,0 +1,169 @@ +import { + EnvironmentId, + type PreviewAutomationOwner, + PreviewAutomationOperation, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + PreviewTabId, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface PreviewAutomationOperationContext { + readonly requestId: PreviewAutomationRequest["requestId"]; + readonly operation: PreviewAutomationRequest["operation"]; + readonly environmentId: PreviewAutomationOwner["environmentId"]; + readonly threadId: PreviewAutomationRequest["threadId"]; + readonly tabId: Exclude | null; +} + +export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationOverlayTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationNavigationTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + readiness: Schema.Literals(["domContentLoaded", "load"]), + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( + "PreviewAutomationStaleOwnerError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + expectedThreadId: ThreadId, + requestedThreadId: ThreadId, + }, +) { + get responseTag() { + return "PreviewAutomationUnavailableError" as const; + } + + override get message(): string { + return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; + } +} + +export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetUnavailableError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + bridgeAvailable: Schema.Boolean, + }, +) { + get responseTag() { + return "PreviewAutomationTabNotFoundError" as const; + } + + override get message(): string { + return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; + } +} + +export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( + "PreviewAutomationRecordingNotActiveError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + }, +) { + get responseTag() { + return "PreviewAutomationExecutionError" as const; + } + + override get message(): string { + return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; + } +} + +export class PreviewAutomationOperationError extends Schema.TaggedErrorClass()( + "PreviewAutomationOperationError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + cause: Schema.Defect(), + }, +) { + static fromCause( + input: PreviewAutomationOperationContext & { readonly cause: unknown }, + ): PreviewAutomationOwnerError { + return isPreviewAutomationOwnerError(input.cause) + ? input.cause + : new PreviewAutomationOperationError(input); + } + + get responseTag() { + return "PreviewAutomationExecutionError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} failed on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}).`; + } +} + +export const PreviewAutomationOwnerError = Schema.Union([ + PreviewAutomationOverlayTimeoutError, + PreviewAutomationNavigationTimeoutError, + PreviewAutomationStaleOwnerError, + PreviewAutomationTargetUnavailableError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationOperationError, +]); +export type PreviewAutomationOwnerError = typeof PreviewAutomationOwnerError.Type; + +export const isPreviewAutomationOwnerError = Schema.is(PreviewAutomationOwnerError); + +export function serializePreviewAutomationOwnerError( + error: PreviewAutomationOwnerError, +): NonNullable { + const detail = Object.fromEntries( + Object.entries(error).filter( + ([key]) => + key !== "_tag" && key !== "cause" && key !== "name" && key !== "message" && key !== "stack", + ), + ); + return { + _tag: error.responseTag, + message: error.message, + ...(Object.keys(detail).length === 0 ? {} : { detail }), + }; +} diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts index 5cc89c00e9c..905a014d5af 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -1,19 +1,33 @@ -import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts"; -import { ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + PreviewTabId, + ThreadId, +} from "@t3tools/contracts"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; +import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; import { createPreviewAutomationRequestConsumerAtom, serializePreviewAutomationError, } from "./previewAutomationRequestConsumer"; -const request = (requestId: string): PreviewAutomationRequest => ({ +const environmentId = EnvironmentId.make("environment-1"); +const threadId = ThreadId.make("thread-1"); +const tabId = PreviewTabId.make("tab-1"); + +const request = ( + requestId: string, + overrides: Partial = {}, +): PreviewAutomationRequest => ({ requestId, - threadId: ThreadId.make("thread-1"), + threadId, operation: "status", input: {}, timeoutMs: 15_000, + ...overrides, }); describe("previewAutomationRequestConsumer", () => { @@ -30,6 +44,7 @@ describe("previewAutomationRequestConsumer", () => { }); const consumerAtom = createPreviewAutomationRequestConsumerAtom({ requestsAtom, + environmentId, handleRequest, respond, label: "test:preview-automation-consumer", @@ -56,6 +71,7 @@ describe("previewAutomationRequestConsumer", () => { const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); const consumerAtom = createPreviewAutomationRequestConsumerAtom({ requestsAtom, + environmentId, handleRequest: async () => undefined, respond, label: "test:preview-automation-initial-request", @@ -69,33 +85,112 @@ describe("previewAutomationRequestConsumer", () => { registry.dispose(); }); - it("preserves typed automation errors in responses", () => { - const error = new Error("No preview tab"); - error.name = "PreviewAutomationTabNotFoundError"; + it("preserves tagged automation errors and their structured diagnostics", () => { + const error = new PreviewAutomationTargetUnavailableError({ + requestId: "request-1", + operation: "click", + environmentId, + threadId, + tabId, + bridgeAvailable: false, + }); - expect(serializePreviewAutomationError(error)).toEqual({ + expect( + serializePreviewAutomationError(error, { + requestId: "request-1", + operation: "click", + environmentId, + threadId, + tabId, + }), + ).toEqual({ _tag: "PreviewAutomationTabNotFoundError", - message: "No preview tab", + message: + "Preview automation target for click request request-1 is unavailable on environment environment-1 thread thread-1 (tab tab-1, bridge unavailable).", + detail: { + requestId: "request-1", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + bridgeAvailable: false, + }, }); }); - it("serializes structured automation context without leaking causes", () => { - const error = Object.assign(new Error("Preview target unavailable"), { - name: "PreviewAutomationTargetUnavailableError", - _tag: "PreviewAutomationTargetUnavailableError", - responseTag: "PreviewAutomationTabNotFoundError", - requestId: "request-1", - threadId: "thread-1", - cause: new Error("private bridge failure"), - }); + it("correlates unexpected failures without exposing cause details", () => { + const cause = new Error("private bridge token: preview-secret"); + const context = { + requestId: "request-2", + operation: "snapshot" as const, + environmentId, + threadId, + tabId, + }; + const response = serializePreviewAutomationError(cause, context); - expect(serializePreviewAutomationError(error)).toEqual({ - _tag: "PreviewAutomationTabNotFoundError", - message: "Preview target unavailable", + expect(response).toEqual({ + _tag: "PreviewAutomationExecutionError", + message: + "Preview automation snapshot request request-2 failed on environment environment-1 thread thread-1 (tab tab-1).", detail: { - requestId: "request-1", + requestId: "request-2", + operation: "snapshot", + environmentId: "environment-1", threadId: "thread-1", + tabId: "tab-1", }, }); + expect(JSON.stringify(response)).not.toContain("preview-secret"); + }); + + it("sanitizes unexpected handler failures at the response boundary", async () => { + const requestsAtom = Atom.make>( + AsyncResult.initial(false), + ); + const responses: PreviewAutomationResponse[] = []; + const consumerAtom = createPreviewAutomationRequestConsumerAtom({ + requestsAtom, + environmentId, + handleRequest: async () => { + throw new Error("desktop IPC secret: do-not-return"); + }, + respond: async (response) => { + responses.push(response); + }, + label: "test:preview-automation-failure-boundary", + }); + const registry = AtomRegistry.make(); + registry.mount(consumerAtom); + + registry.set( + requestsAtom, + AsyncResult.success( + request("request-failed", { + operation: "click", + tabId, + }), + ), + ); + + await vi.waitFor(() => expect(responses).toHaveLength(1)); + expect(responses[0]).toEqual({ + requestId: "request-failed", + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message: + "Preview automation click request request-failed failed on environment environment-1 thread thread-1 (tab tab-1).", + detail: { + requestId: "request-failed", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + }, + }, + }); + expect(JSON.stringify(responses[0])).not.toContain("do-not-return"); + registry.dispose(); }); }); diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts index 5cf5590335f..37983b0255e 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts @@ -1,6 +1,16 @@ -import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts"; +import type { + PreviewAutomationOwner, + PreviewAutomationRequest, + PreviewAutomationResponse, +} from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { + PreviewAutomationOperationError, + type PreviewAutomationOperationContext, + serializePreviewAutomationOwnerError, +} from "./previewAutomationErrors"; + type AutomationRequestResult = AsyncResult.AsyncResult; type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise; @@ -19,54 +29,16 @@ export function createLatestPreviewAutomationRequestHandler(initial: AutomationR export function serializePreviewAutomationError( error: unknown, + context: PreviewAutomationOperationContext, ): NonNullable { - if (error instanceof Error) { - const explicitDetail = - "detail" in error && (error as { detail?: unknown }).detail !== undefined - ? (error as { detail?: unknown }).detail - : undefined; - const structuralDetail = - "_tag" in error && - typeof (error as { _tag?: unknown })._tag === "string" && - (error as { _tag: string })._tag.startsWith("PreviewAutomation") - ? Object.fromEntries( - Object.entries(error).filter( - ([key]) => - key !== "_tag" && - key !== "cause" && - key !== "name" && - key !== "message" && - key !== "stack" && - key !== "detail" && - key !== "responseTag", - ), - ) - : undefined; - const detail = explicitDetail ?? structuralDetail; - const responseTag = - "responseTag" in error && - typeof (error as { responseTag?: unknown }).responseTag === "string" && - (error as { responseTag: string }).responseTag.startsWith("PreviewAutomation") - ? (error as { responseTag: string }).responseTag - : undefined; - return { - _tag: - responseTag ?? - (error.name.startsWith("PreviewAutomation") - ? error.name - : "PreviewAutomationExecutionError"), - message: error.message, - ...(detail === undefined ? {} : { detail }), - }; - } - return { - _tag: "PreviewAutomationExecutionError", - message: String(error), - }; + return serializePreviewAutomationOwnerError( + PreviewAutomationOperationError.fromCause({ ...context, cause: error }), + ); } export function createPreviewAutomationRequestConsumerAtom(options: { readonly requestsAtom: Atom.Atom>; + readonly environmentId: PreviewAutomationOwner["environmentId"]; readonly handleRequest: (request: PreviewAutomationRequest) => Promise; readonly respond: (response: PreviewAutomationResponse) => Promise; readonly label: string; @@ -89,7 +61,13 @@ export function createPreviewAutomationRequestConsumerAtom(options: { options.respond({ requestId: request.requestId, ok: false, - error: serializePreviewAutomationError(error), + error: serializePreviewAutomationError(error, { + requestId: request.requestId, + operation: request.operation, + environmentId: options.environmentId, + threadId: request.threadId, + tabId: request.tabId ?? null, + }), }), ); }; From b51aef10c4f21c35f195b81e548c052041a15051 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:22 -0700 Subject: [PATCH 179/257] [codex] Preserve cloud disconnect diagnostics (#3437) Co-authored-by: codex --- apps/server/src/cli/connect.test.ts | 55 ++++++++++++++++++++- apps/server/src/cli/connect.ts | 76 ++++++++++++++++++++++------- 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 5fce3bc1cd7..70b0329ac90 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -1,9 +1,14 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as References from "effect/References"; -import { acquireRelayClientForLink } from "./connect.ts"; +import { acquireRelayClientForLink, reportCloudDisconnectResults } from "./connect.ts"; const managedExecutable = { status: "available", @@ -100,3 +105,51 @@ it.effect("reuses an available relay client executable without prompting", () => assert.equal(promptCalls, 0); }), ); + +it.effect("keeps disconnect causes in structured logs and out of console warnings", () => { + const warnings: ReadonlyArray[] = []; + const logs: Readonly>[] = []; + const testConsole = { + ...globalThis.console, + warn: (...args: ReadonlyArray) => { + warnings.push(args); + }, + } satisfies Console.Console; + const logger = Logger.make(({ fiber }) => { + logs.push(fiber.getRef(References.CurrentLogAnnotations)); + }); + const liveFailure = "live unlink private diagnostic"; + const relayFailure = "relay revoke private diagnostic"; + + return reportCloudDisconnectResults({ + clearAuthorization: true, + liveResult: { + status: "failed", + cause: Cause.fail(new Error(liveFailure)), + }, + relayResult: Exit.failCause(Cause.die(new Error(relayFailure))), + }).pipe( + Effect.provideService(Console.Console, testConsole), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + Effect.tap(() => + Effect.sync(() => { + assert.lengthOf(warnings, 2); + const warningText = warnings.flat().map(String).join("\n"); + assert.include(warningText, "running server could not stop its tunnel"); + assert.include(warningText, "Could not revoke the relay-side environment record"); + assert.notInclude(warningText, liveFailure); + assert.notInclude(warningText, relayFailure); + assert.deepEqual( + logs.map(({ operation, clearAuthorization }) => ({ operation, clearAuthorization })), + [ + { operation: "live-server-unlink", clearAuthorization: true }, + { operation: "relay-environment-unlink", clearAuthorization: true }, + ], + ); + const loggedCauses = logs.map((log) => String(log.cause)).join("\n"); + assert.include(loggedCauses, liveFailure); + assert.include(loggedCauses, relayFailure); + }), + ), + ); +}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 314680b0d80..3ce53391fa6 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -7,6 +7,7 @@ import { import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -179,7 +180,7 @@ const withCloudCliSessionToken = ( type LiveCloudActionResult = | { readonly status: "not-running" } | { readonly status: "succeeded" } - | { readonly status: "failed"; readonly cause: unknown }; + | { readonly status: "failed"; readonly cause: Cause.Cause }; const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () { const config = yield* ServerConfig.ServerConfig; @@ -211,6 +212,21 @@ type RelayUnlinkResult = | { readonly status: "revoked" } | { readonly status: "not-linked" }; +type CloudDisconnectOperation = "live-server-unlink" | "relay-environment-unlink"; + +const logCloudDisconnectFailure = ( + operation: CloudDisconnectOperation, + clearAuthorization: boolean, + cause: Cause.Cause, +) => + Effect.logWarning("T3 Connect disconnect operation failed.").pipe( + Effect.annotateLogs({ + operation, + clearAuthorization, + cause: Cause.pretty(cause), + }), + ); + const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () { const tokens = yield* CliTokenManager.CloudCliTokenManager; const token = yield* tokens.getExisting; @@ -236,6 +252,42 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f : ({ status: "not-linked" } satisfies RelayUnlinkResult); }); +export const reportCloudDisconnectResults = Effect.fn("cloud.cli.report_disconnect_results")( + function* (input: { + readonly clearAuthorization: boolean; + readonly liveResult: LiveCloudActionResult; + readonly relayResult: Exit.Exit; + }) { + if (input.liveResult.status === "failed") { + yield* logCloudDisconnectFailure( + "live-server-unlink", + input.clearAuthorization, + input.liveResult.cause, + ); + yield* Console.warn( + "T3 Connect is disabled, but the running server could not stop its tunnel.\nRestart that server to stop the connector.", + ); + } else { + yield* Console.log("T3 Connect is disabled locally."); + } + + if (Exit.isFailure(input.relayResult)) { + yield* logCloudDisconnectFailure( + "relay-environment-unlink", + input.clearAuthorization, + input.relayResult.cause, + ); + yield* Console.warn( + input.clearAuthorization + ? "Could not revoke the relay-side environment record before signing out.\nThe stored CLI authorization was still removed locally." + : "Could not revoke the relay-side environment record yet.\nRun `t3 connect unlink` again when the relay is reachable.", + ); + } else if (input.relayResult.value.status === "revoked") { + yield* Console.log("Revoked the relay-side environment record."); + } + }, +); + const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { readonly clearAuthorization: boolean; }) { @@ -249,23 +301,11 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { yield* tokens.clear; } - if (liveResult.status === "failed") { - yield* Console.warn( - `T3 Connect is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`, - ); - } else { - yield* Console.log("T3 Connect is disabled locally."); - } - - if (Exit.isFailure(relayResult)) { - yield* Console.warn( - options.clearAuthorization - ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.` - : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 connect unlink\` again when the relay is reachable.`, - ); - } else if (relayResult.value.status === "revoked") { - yield* Console.log("Revoked the relay-side environment record."); - } + yield* reportCloudDisconnectResults({ + clearAuthorization: options.clearAuthorization, + liveResult, + relayResult, + }); if (options.clearAuthorization) { yield* Console.log("Signed out of T3 Connect locally."); From b6c590af389d09400fa4f99a56ffc33e9ed4ebf1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:27 -0700 Subject: [PATCH 180/257] [codex] Fix desktop preview event delivery errors (#3435) Co-authored-by: codex --- apps/desktop/src/ipc/methods/preview.ts | 29 ++++------ apps/desktop/src/preview/Manager.test.ts | 74 +++++++++++++++++++++++- apps/desktop/src/preview/Manager.ts | 30 ++++++++-- 3 files changed, 108 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 1994c270024..2abf53ac284 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -19,37 +19,30 @@ import { PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; -import { BrowserWindow } from "electron"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as NodeURL from "node:url"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; -const broadcast = (channel: string, ...args: ReadonlyArray): void => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send(channel, ...args); - } - } -}; - export const installPreviewEventForwarding = Effect.fn( "desktop.ipc.preview.installEventForwarding", )(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; const manager = yield* PreviewManager.PreviewManager; - yield* manager.subscribeStateChanges((tabId, state) => { - broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); - }); - yield* manager.subscribeRecordingFrames((frame) => { - broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); - }); - yield* manager.subscribePointerEvents((event) => { - broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); - }); + yield* manager.subscribeStateChanges((tabId, state) => + electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), + ); + yield* manager.subscribeRecordingFrames((frame) => + electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), + ); + yield* manager.subscribePointerEvents((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), + ); }); export const createTab = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index cc83d5f2e37..acb0d783a82 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -13,6 +14,7 @@ import { TestClock } from "effect/testing"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; @@ -130,6 +132,72 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { + const loggedErrors: Array = []; + const logger = Logger.make(({ message }) => { + for (const value of Array.isArray(message) ? message : [message]) { + if (typeof value === "object" && value !== null && "cause" in value) { + loggedErrors.push(Cause.squash(value.cause as Cause.Cause)); + } + } + }); + const deliveryError = new ElectronWindow.ElectronWindowOperationError({ + operation: "send-window-message", + platform: "darwin", + windowId: 42, + channel: "preview:state-change", + cause: new Error("renderer unavailable"), + }); + const delivered = vi.fn(); + + return withManager((manager) => + Effect.gen(function* () { + yield* manager.subscribeStateChanges(() => Effect.die(deliveryError)); + yield* manager.subscribeStateChanges((tabId, state) => + Effect.sync(() => { + delivered(tabId, state); + }), + ); + + const state = yield* manager.createTab("tab_listener_failure"); + + expect(delivered).toHaveBeenCalledOnce(); + expect(delivered).toHaveBeenCalledWith("tab_listener_failure", state); + expect(loggedErrors).toHaveLength(1); + expect(loggedErrors[0]).toBeInstanceOf(ElectronWindow.ElectronWindowOperationError); + expect(loggedErrors[0]).toMatchObject({ + operation: "send-window-message", + windowId: 42, + channel: "preview:state-change", + }); + }), + ).pipe( + Effect.provide( + Logger.layer([logger], { + mergeWithExisting: false, + }), + ), + ); + }); + + effectIt.effect("does not swallow state listener interruption", () => + withManager((manager) => + Effect.gen(function* () { + const exit = yield* Effect.scoped( + Effect.gen(function* () { + yield* manager.subscribeStateChanges(() => Effect.interrupt); + return yield* Effect.exit(manager.createTab("tab_interrupted_listener")); + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + } + }), + ), + ); + effectIt.effect("queues navigation until the webview registers", () => withManager((manager) => Effect.gen(function* () { @@ -411,7 +479,11 @@ describe("PreviewManager", () => { }, } as never); - yield* manager.subscribePointerEvents((event) => activity.push(event.phase)); + yield* manager.subscribePointerEvents((event) => + Effect.sync(() => { + activity.push(event.phase); + }), + ); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); const click = yield* manager diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index bb3e1fcef93..6fd65cd25b5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -281,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; }; -type Listener = (tabId: string, state: PreviewTabState) => void; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; +type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; +type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -313,7 +313,7 @@ interface BrowserDiagnostics { readonly requests: ReadonlyMap; } -type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; +type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; interface ExpectedAgentInput { readonly signal: PreviewInputSignal; @@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return copy; }; + const deliverEvent = ( + eventKind: "state-change" | "recording-frame" | "pointer-event", + tabId: string, + delivery: () => Effect.Effect, + ) => + Effect.suspend(delivery).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("Desktop preview event listener failed.", { + eventKind, + tabId, + cause, + }), + ), + ); + const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { const listeners = yield* Ref.get(listenersRef); yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore), + (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)), { discard: true }, ); }); @@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), + (listener) => + deliverEvent("recording-frame", frame.tabId, () => listener(frame)), { discard: true }, ); } @@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const listeners = yield* Ref.get(pointerEventListenersRef); yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore), + (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)), { discard: true }, ); }); From 6d8d995621065e0513def2fa28bf0f7d8b54dd90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:31 -0700 Subject: [PATCH 181/257] [codex] Correlate protocol request failures (#3433) Co-authored-by: codex --- packages/effect-acp/src/_internal/shared.ts | 2 +- packages/effect-acp/src/errors.test.ts | 2 + packages/effect-acp/src/errors.ts | 66 ++++++++++++- packages/effect-acp/src/protocol.test.ts | 74 +++++++++++++- packages/effect-acp/src/protocol.ts | 98 +++++++++++++------ .../effect-codex-app-server/src/errors.ts | 16 ++- .../src/protocol.test.ts | 53 +++++++++- .../effect-codex-app-server/src/protocol.ts | 62 +++++++----- 8 files changed, 314 insertions(+), 59 deletions(-) diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index 7e43bbf8831..f54f81e2a08 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -12,7 +12,7 @@ export const callRpc = ( ): Effect.Effect => effect.pipe( Effect.catchIf(isError, (error) => - Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)), + Effect.fail(AcpError.AcpRequestError.fromProtocolError(error, { method })), ), Effect.catchTags({ RpcClientError: (cause) => diff --git a/packages/effect-acp/src/errors.test.ts b/packages/effect-acp/src/errors.test.ts index 5187fabf5d2..a54c5af4843 100644 --- a/packages/effect-acp/src/errors.test.ts +++ b/packages/effect-acp/src/errors.test.ts @@ -51,6 +51,8 @@ describe("effect-acp errors", () => { code: -32602, errorMessage: "Invalid params", data: { field: "sessionId" }, + method: "session/load", + operation: "receive-response", }); }); }); diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index b3c0dee6294..3fe0a469001 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -5,8 +5,11 @@ import * as AcpSchema from "./_generated/schema.gen.ts"; export const AcpRequestOperation = Schema.Literals([ "decode-extension-request-payload", + "encode-extension-response", "handle-request", "handle-extension-request", + "receive-response", + "receive-streaming-response", ]); export type AcpRequestOperation = typeof AcpRequestOperation.Type; @@ -65,6 +68,7 @@ const schemaIssueDiagnostics = (root: SchemaIssue.Issue): AcpSchemaIssueDiagnost export interface AcpRequestDiagnostics { readonly method?: string; + readonly requestId?: string; readonly operation?: AcpRequestOperation; readonly cause?: unknown; readonly issueCount?: number; @@ -109,6 +113,7 @@ export class AcpProtocolParseError extends Schema.TaggedErrorClass()( @@ -167,6 +185,7 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( errorMessage: Schema.String, data: Schema.optional(Schema.Unknown), method: Schema.optionalKey(Schema.String), + requestId: Schema.optionalKey(Schema.String), operation: Schema.optionalKey(AcpRequestOperation), issueCount: Schema.optionalKey(Schema.Number), issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)), @@ -177,14 +196,59 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( return this.errorMessage; } - static fromProtocolError(error: AcpSchema.Error) { + static fromProtocolError( + error: AcpSchema.Error, + context: { + readonly method: string; + readonly requestId?: string; + readonly cause?: unknown; + }, + ) { return new AcpRequestError({ code: error.code, errorMessage: error.message, ...(error.data !== undefined ? { data: error.data } : {}), + method: context.method, + ...(context.requestId === undefined ? {} : { requestId: context.requestId }), + operation: "receive-response", + cause: context.cause ?? error, }); } + static fromExtensionResponseFailure(method: string, requestId: string, cause: unknown) { + return AcpRequestError.internalError("Extension request failed", undefined, { + method, + requestId, + operation: "receive-response", + cause, + }); + } + + static fromExtensionResponseEncodingError( + method: string, + requestId: string, + cause: AcpProtocolParseError, + ) { + return AcpRequestError.internalError("Internal error", undefined, { + method, + requestId, + operation: "encode-extension-response", + cause, + }); + } + + static unsupportedStreamingResponse(method: string, requestId: string) { + return AcpRequestError.internalError( + "Streaming extension responses are not supported", + undefined, + { + method, + requestId, + operation: "receive-streaming-response", + }, + ); + } + static fromCoreHandlerError(error: AcpError, method: string) { if (error._tag === "AcpRequestError") { return error; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index c8e03dd7235..ece068dfc88 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -260,15 +260,33 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); assert.equal(bigintError.operation, "encode-message"); + assert.equal(bigintError.method, "x/test"); assert.instanceOf(bigintError.cause, TypeError); - assert.equal(bigintError.message, "ACP protocol operation 'encode-message' failed."); + assert.equal( + bigintError.message, + "ACP protocol operation 'encode-message' failed for method 'x/test'.", + ); const circular: Record = {}; circular.self = circular; const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, AcpError.AcpProtocolParseError); assert.equal(circularError.operation, "encode-message"); + assert.equal(circularError.method, "x/test"); assert.instanceOf(circularError.cause, TypeError); + + const requestError = yield* transport.request("x/request", 1n).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected request encoding to fail"), + }), + ); + assert.instanceOf(requestError, AcpError.AcpProtocolParseError); + assert.deepInclude(requestError, { + operation: "encode-message", + method: "x/request", + requestId: "1", + }); }), ); @@ -310,6 +328,60 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("correlates extension response errors with the originating request", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const response = yield* transport + .request("x/private", { hello: "world" }) + .pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + id: 1, + error: { + _tag: "Cause", + code: -32602, + message: "Invalid params", + data: [ + { + _tag: "Fail", + error: { + code: -32602, + message: "Invalid params", + data: { field: "hello" }, + }, + }, + ], + }, + })}\n`, + ), + ); + + const error = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected extension request to fail"), + }), + ); + assert.instanceOf(error, AcpError.AcpRequestError); + assert.deepInclude(error, { + code: -32602, + errorMessage: "Invalid params", + method: "x/private", + requestId: "1", + operation: "receive-response", + }); + }), + ); + it.effect("preserves zero-valued ids for inbound core client requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 6c3bd399028..27c619296c0 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -66,6 +66,11 @@ export interface AcpPatchedProtocol { readonly notify: (method: string, payload: unknown) => Effect.Effect; } +interface AcpPendingRequest { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); const decodeElicitationComplete = Schema.decodeUnknownEffect( AcpSchema.ElicitationCompleteNotification, @@ -83,9 +88,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const outgoing = yield* Queue.unbounded>(); const nextRequestId = yield* Ref.make(1n); const terminationHandled = yield* Ref.make(false); - const extPending = yield* Ref.make( - new Map>(), - ); + const extPending = yield* Ref.make(new Map()); const logProtocol = (event: AcpProtocolLogEvent) => { if (event.direction === "incoming" && !options.logIncoming) { @@ -109,13 +112,17 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi payload: message, }); + const method = message._tag === "Request" ? message.tag : undefined; + const encodedRequestId = + message._tag === "Request" + ? message.id + : "requestId" in message + ? message.requestId + : undefined; + const requestId = encodedRequestId === "" ? undefined : encodedRequestId; const encoded = yield* Effect.try({ try: () => parser.encode(message), - catch: (cause) => - new AcpError.AcpProtocolParseError({ - operation: "encode-message", - cause, - }), + catch: (cause) => AcpError.AcpProtocolParseError.fromEncodingError(method, requestId, cause), }); if (encoded) { @@ -131,16 +138,16 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const resolveExtPending = ( requestId: string, - onFound: (deferred: Deferred.Deferred) => Effect.Effect, + onFound: (pendingRequest: AcpPendingRequest) => Effect.Effect, ) => Ref.modify(extPending, (pending) => { - const deferred = pending.get(requestId); - if (!deferred) { + const pendingRequest = pending.get(requestId); + if (!pendingRequest) { return [Effect.void, pending] as const; } const next = new Map(pending); next.delete(requestId); - return [onFound(deferred), next] as const; + return [onFound(pendingRequest), next] as const; }).pipe(Effect.flatten); const removeExtPending = (requestId: string) => @@ -154,15 +161,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }); const completeExtPendingFailure = (requestId: string, error: AcpError.AcpError) => - resolveExtPending(requestId, (deferred) => Deferred.fail(deferred, error)); + resolveExtPending(requestId, ({ deferred }) => Deferred.fail(deferred, error)); const completeExtPendingSuccess = (requestId: string, value: unknown) => - resolveExtPending(requestId, (deferred) => Deferred.succeed(deferred, value)); + resolveExtPending(requestId, ({ deferred }) => Deferred.succeed(deferred, value)); const failAllExtPending = (error: AcpError.AcpError) => Ref.getAndSet(extPending, new Map()).pipe( Effect.flatMap((pending) => - Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), { + Effect.forEach([...pending.values()], ({ deferred }) => Deferred.fail(deferred, error), { discard: true, }), ), @@ -303,7 +310,26 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi if (!options.serverRequestMethods.has(message.tag)) { return handleExtRequest(message).pipe( - Effect.catch(() => respondWithError(message.id, AcpError.AcpRequestError.internalError())), + Effect.catchTags({ + AcpProtocolParseError: (error) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + method: message.tag, + requestId: message.id, + operation: error.operation, + }), + Effect.andThen( + respondWithError( + message.id, + AcpError.AcpRequestError.fromExtensionResponseEncodingError( + message.tag, + message.id, + error, + ), + ), + ), + ), + }), Effect.asVoid, ); } @@ -314,7 +340,8 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => Ref.get(extPending).pipe( Effect.flatMap((pending) => { - if (!pending.has(message.requestId)) { + const pendingRequest = pending.get(message.requestId); + if (!pendingRequest) { return Queue.offer(clientQueue, message).pipe(Effect.asVoid); } if (message.exit._tag === "Success") { @@ -324,12 +351,20 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi if (failure && isProtocolError(failure.error)) { return completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.fromProtocolError(failure.error), + AcpError.AcpRequestError.fromProtocolError(failure.error, { + method: pendingRequest.method, + requestId: message.requestId, + cause: message.exit.cause, + }), ); } return completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.internalError("Extension request failed"), + AcpError.AcpRequestError.fromExtensionResponseFailure( + pendingRequest.method, + message.requestId, + message.exit.cause, + ), ); }), ); @@ -344,16 +379,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi return handleExitEncoded(message); case "Chunk": return Ref.get(extPending).pipe( - Effect.flatMap((pending) => - pending.has(message.requestId) + Effect.flatMap((pending) => { + const pendingRequest = pending.get(message.requestId); + return pendingRequest ? completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.internalError( - "Streaming extension responses are not supported", + AcpError.AcpRequestError.unsupportedStreamingResponse( + pendingRequest.method, + message.requestId, ), ) - : Queue.offer(clientQueue, message).pipe(Effect.asVoid), - ), + : Queue.offer(clientQueue, message).pipe(Effect.asVoid); + }), ); case "Defect": case "ClientProtocolError": @@ -401,6 +438,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi payload: { operation: error.operation, ...(error.method === undefined ? {} : { method: error.method }), + ...(error.requestId === undefined ? {} : { requestId: error.requestId }), ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), ...(error.maximumPathDepth === undefined @@ -494,18 +532,16 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi (current) => [current, current + 1n] as const, ); const deferred = yield* Deferred.make(); - yield* Ref.update(extPending, (pending) => new Map(pending).set(String(requestId), deferred)); + yield* Ref.update(extPending, (pending) => + new Map(pending).set(String(requestId), { deferred, method }), + ); yield* offerOutgoing({ _tag: "Request", id: String(requestId), tag: method, payload, headers: [], - }).pipe( - Effect.catch((error) => - removeExtPending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), - ), - ); + }).pipe(Effect.tapError(() => removeExtPending(String(requestId)))); return yield* Deferred.await(deferred).pipe( Effect.onInterrupt(() => removeExtPending(String(requestId))), ); diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 2f769f47de2..2559bba618c 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -5,6 +5,7 @@ export const CodexAppServerRequestOperation = Schema.Literals([ "decode-payload", "encode-payload", "handle-request", + "receive-response", ]); export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; @@ -83,6 +84,7 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { export interface CodexAppServerRequestDiagnostics { readonly method?: string; + readonly requestId?: string; readonly operation?: CodexAppServerRequestOperation; readonly cause?: unknown; readonly issueCount?: number; @@ -154,6 +156,7 @@ export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, CodexError.CodexAppServerProtocolParseError); assert.equal(bigintError.operation, "encode-wire-message"); + assert.equal(bigintError.method, "x/test"); assert.exists(bigintError.cause); assert.equal( bigintError.message, - "Codex App Server protocol operation 'encode-wire-message' failed.", + "Codex App Server protocol operation 'encode-wire-message' failed for method 'x/test'.", ); const circular: Record = {}; @@ -223,7 +224,57 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, CodexError.CodexAppServerProtocolParseError); assert.equal(circularError.operation, "encode-wire-message"); + assert.equal(circularError.method, "x/test"); assert.exists(circularError.cause); + + const requestError = yield* transport.request("x/request", 1n).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected request encoding to fail"), + }), + ); + assert.instanceOf(requestError, CodexError.CodexAppServerProtocolParseError); + assert.deepInclude(requestError, { + operation: "encode-wire-message", + method: "x/request", + requestId: "1", + }); + }), + ); + + it.effect("correlates response errors with the originating request", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ stdio }); + + const response = yield* transport.request("thread/start", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encodeJsonl({ + id: 1, + error: { + code: -32602, + message: "Invalid params", + data: { field: "cwd" }, + }, + }), + ); + + const error = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected Codex App Server request to fail"), + }), + ); + assert.instanceOf(error, CodexError.CodexAppServerRequestError); + assert.deepInclude(error, { + code: -32602, + errorMessage: "Invalid params", + method: "thread/start", + requestId: "1", + operation: "receive-response", + }); }), ); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index c0f07f95a5a..fbf173cbc5e 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -67,6 +67,11 @@ export interface CodexAppServerPatchedProtocol { ) => Effect.Effect; } +interface CodexAppServerPendingRequest { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -94,9 +99,21 @@ const encodeWireMessage = ( ): Effect.Effect => encodeJsonString(message).pipe( Effect.map((encoded) => `${encoded}\n`), - Effect.mapError((cause) => - CodexError.CodexAppServerProtocolParseError.fromSchemaError("encode-wire-message", cause), - ), + Effect.mapError((cause) => { + const method = typeof message.method === "string" ? message.method : undefined; + const requestId = + typeof message.id === "string" || typeof message.id === "number" + ? String(message.id) + : undefined; + return CodexError.CodexAppServerProtocolParseError.fromSchemaError( + "encode-wire-message", + cause, + { + ...(method === undefined ? {} : { method }), + ...(requestId === undefined ? {} : { requestId }), + }, + ); + }), ); const decodeWireMessage = ( @@ -138,9 +155,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const outgoing = yield* Queue.unbounded>(); const incomingNotifications = yield* Queue.unbounded(); const incomingRequests = yield* Queue.unbounded(); - const pending = yield* Ref.make( - new Map>(), - ); + const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); const remainder = yield* Ref.make(""); const terminationHandled = yield* Ref.make(false); @@ -161,7 +176,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const failAllPending = (error: CodexError.CodexAppServerError) => Ref.get(pending).pipe( Effect.flatMap((current) => - Effect.forEach([...current.values()], (deferred) => Deferred.fail(deferred, error), { + Effect.forEach([...current.values()], ({ deferred }) => Deferred.fail(deferred, error), { discard: true, }), ), @@ -214,18 +229,16 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const resolvePending = ( requestId: string, - handler: ( - deferred: Deferred.Deferred, - ) => Effect.Effect, + handler: (pendingRequest: CodexAppServerPendingRequest) => Effect.Effect, ) => Ref.modify(pending, (current) => { - const deferred = current.get(requestId); - if (!deferred) { + const pendingRequest = current.get(requestId); + if (!pendingRequest) { return [Effect.void, current] as const; } const next = new Map(current); next.delete(requestId); - return [handler(deferred), next] as const; + return [handler(pendingRequest), next] as const; }).pipe(Effect.flatten); const respond = (requestId: string | number, result: unknown) => @@ -240,14 +253,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const requestId = String(response.id); const protocolError = response.error; if (protocolError !== undefined) { - return resolvePending(requestId, (deferred) => + return resolvePending(requestId, ({ deferred, method }) => Deferred.fail( deferred, - CodexError.CodexAppServerRequestError.fromProtocolError(protocolError), + CodexError.CodexAppServerRequestError.fromProtocolError( + protocolError, + method, + requestId, + ), ), ); } - return resolvePending(requestId, (deferred) => Deferred.succeed(deferred, response.result)); + return resolvePending(requestId, ({ deferred }) => + Deferred.succeed(deferred, response.result), + ); }; const handleRequest = (request: CodexAppServerIncomingRequest) => @@ -322,6 +341,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa payload: { operation: error.operation, ...(error.method === undefined ? {} : { method: error.method }), + ...(error.requestId === undefined ? {} : { requestId: error.requestId }), ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), ...(error.maximumPathDepth === undefined @@ -375,16 +395,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa (current) => [current, current + 1] as const, ); const deferred = yield* Deferred.make(); - yield* Ref.update(pending, (current) => new Map(current).set(String(requestId), deferred)); + yield* Ref.update(pending, (current) => + new Map(current).set(String(requestId), { deferred, method }), + ); yield* offerOutgoing({ id: requestId, method, ...(payload !== undefined ? { params: payload } : {}), - }).pipe( - Effect.catch((error) => - removePending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), - ), - ); + }).pipe(Effect.tapError(() => removePending(String(requestId)))); return yield* Deferred.await(deferred).pipe( Effect.onInterrupt(() => removePending(String(requestId))), ); From dd48bfd98c440029c1ecaf946916105850cfd0ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:36 -0700 Subject: [PATCH 182/257] [codex] Structure server settings failures (#3376) Co-authored-by: codex --- apps/server/src/serverRuntimeStartup.ts | 4 +- apps/server/src/serverSettings.test.ts | 57 ++++++++++++ apps/server/src/serverSettings.ts | 115 ++++++++++++++---------- packages/contracts/src/settings.ts | 27 +++++- 4 files changed, 150 insertions(+), 53 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cbdf58c4d67..b52b577c5b5 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -327,7 +327,9 @@ export const make = Effect.gen(function* () { Effect.catch((error) => Effect.logWarning("failed to start server settings runtime", { path: error.settingsPath, - detail: error.detail, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, cause: error.cause, }), ), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 87feee669ec..504d99e18de 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; @@ -32,7 +33,63 @@ const makeServerSettingsLayer = () => ), ); +const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => + Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.fail(cause), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: () => Effect.void, + }), + ); + it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("preserves context when reading a provider environment secret fails", () => { + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "provider environment secret", + description: "Secret backend unavailable.", + }); + const cause = new ServerSecretStore.SecretStoreReadError({ + resource: "provider environment secret", + cause: platformCause, + }); + const configLayer = Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-secret-failure-test-", + }), + ); + const settingsLayer = ServerSettingsModule.layer.pipe( + Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(configLayer), + ); + + return Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"OPENROUTER_API_KEY","value":"","sensitive":true,"valueRedacted":true}],"config":{}}}}', + ); + + const error = yield* Effect.flip(serverSettings.getSettings); + + assert.deepInclude(error, { + _tag: "ServerSettingsError", + operation: "read-secret", + providerInstanceId: "codex_personal", + environmentVariable: "OPENROUTER_API_KEY", + }); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(settingsLayer)); + }); + it.effect("decodes nested settings patches", () => Effect.gen(function* () { assert.deepEqual( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a5fcdc30c02..4119a72640f 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -40,7 +40,6 @@ import * as Path from "effect/Path"; 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 Stream from "effect/Stream"; @@ -67,7 +66,7 @@ const normalizeServerSettings = ( (cause) => new ServerSettingsError({ settingsPath: "", - detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + operation: "normalize", cause, }), ), @@ -277,7 +276,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to check settings file existence", + operation: "check-exists", cause, }), ), @@ -288,7 +287,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to read settings file", + operation: "read-file", cause, }), ), @@ -305,6 +304,7 @@ const make = Effect.gen(function* () { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(decoded.cause), + cause: decoded.cause, }); return DEFAULT_SERVER_SETTINGS; } @@ -318,13 +318,6 @@ const make = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); - const toSettingsError = (detail: string, cause: unknown) => - new ServerSettingsError({ - settingsPath, - detail, - cause, - }); - const materializeProviderEnvironmentSecrets = ( settings: ServerSettings, ): Effect.Effect => @@ -343,11 +336,15 @@ const make = Effect.gen(function* () { const secret = yield* secretStore .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) .pipe( - Effect.mapError((cause) => - toSettingsError( - `failed to read sensitive environment variable ${variable.name}`, - cause, - ), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), ), ); environment.push({ @@ -382,13 +379,18 @@ const make = Effect.gen(function* () { for (const variable of instance.environment) { const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (!variable.sensitive) { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to remove environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); environment.push(redactProviderEnvironmentVariable(variable)); continue; } @@ -396,22 +398,32 @@ const make = Effect.gen(function* () { nextSecretKeys.add(secretName); if (!variable.valueRedacted) { if (variable.value.length > 0) { - yield* secretStore - .set(secretName, textEncoder.encode(variable.value)) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to persist environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.set(secretName, textEncoder.encode(variable.value)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); environment.push({ ...variable, value: "", valueRedacted: true }); } else { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to remove environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); const { valueRedacted: _omit, ...rest } = variable; environment.push(rest); } @@ -431,16 +443,18 @@ const make = Effect.gen(function* () { if (!variable.sensitive) continue; const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (nextSecretKeys.has(secretName)) continue; - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError( - `failed to remove stale environment secret ${variable.name}`, + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-stale-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, cause, - ), - ), - ); + }), + ), + ); } } @@ -468,7 +482,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to write settings file", + operation: "write-file", cause, }), ), @@ -492,7 +506,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to prepare settings directory", + operation: "prepare-directory", cause, }), ), @@ -571,7 +585,10 @@ const make = Effect.gen(function* () { materializeProviderEnvironmentSecrets(settings).pipe( Effect.catch((error: ServerSettingsError) => Effect.logWarning("failed to materialize provider environment secrets", { - detail: error.detail, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, }).pipe(Effect.as(settings)), ), ), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1cb57a98254..7ba267b1e72 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -414,16 +414,37 @@ export type ServerSettings = typeof ServerSettings.Type; export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); +export const ServerSettingsOperation = Schema.Literals([ + "normalize", + "check-exists", + "read-file", + "read-secret", + "remove-secret", + "remove-stale-secret", + "write-secret", + "write-file", + "prepare-directory", +]); +export type ServerSettingsOperation = typeof ServerSettingsOperation.Type; + export class ServerSettingsError extends Schema.TaggedErrorClass()( "ServerSettingsError", { settingsPath: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: ServerSettingsOperation, + providerInstanceId: Schema.optional(Schema.String), + environmentVariable: Schema.optional(Schema.String), + cause: Schema.Defect(), }, ) { override get message(): string { - return `Server settings error at ${this.settingsPath}: ${this.detail}`; + const provider = + this.providerInstanceId === undefined ? "" : ` for provider ${this.providerInstanceId}`; + const variable = + this.environmentVariable === undefined + ? "" + : ` and environment variable ${this.environmentVariable}`; + return `Server settings ${this.operation} failed${provider}${variable} at ${this.settingsPath}.`; } } From faccdd4ca9511204144f3ec8d696a8993d878946 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:40 -0700 Subject: [PATCH 183/257] [codex] Preserve desktop backend log failures (#3375) Co-authored-by: codex --- .../src/app/DesktopBackendOutputLog.test.ts | 122 +++++++++++ .../src/app/DesktopBackendOutputLog.ts | 197 ++++++++++++++---- 2 files changed, 273 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/src/app/DesktopBackendOutputLog.test.ts diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.test.ts b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts new file mode 100644 index 00000000000..18bba9486cb --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts @@ -0,0 +1,122 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import * as DesktopBackendOutputLog from "./DesktopBackendOutputLog.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const LOG_FILE_PATH = "/Users/alice/.t3/userdata/logs/server-child.log"; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +}).pipe(Layer.provide(Layer.merge(Path.layer, DesktopConfig.layerTest({})))); + +const withOutputLog = ( + effect: Effect.Effect, + fileSystemLayer: Layer.Layer, + messages: Array>, +) => { + const logger = Logger.make(({ message }) => { + messages.push(Array.isArray(message) ? message : [message]); + }); + const outputLogLayer = DesktopBackendOutputLog.layer.pipe( + Layer.provide(Layer.mergeAll(fileSystemLayer, Path.layer, environmentLayer)), + Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), + ); + return effect.pipe(Effect.provide(outputLogLayer)); +}; + +const loggedError = (messages: ReadonlyArray>): unknown => + messages.flat().find((value) => typeof value === "object" && value !== null && "error" in value) + ?.error; + +describe("DesktopBackendOutputLog", () => { + it.effect("logs setup failures with the log path and exact cause", () => { + const messages: Array> = []; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: "/Users/alice/.t3/userdata/logs", + description: "private setup diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.fail(cause), + }); + + return withOutputLog( + Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); + + const error = loggedError(messages); + assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogSetupError); + assert.equal(error.logFilePath, LOG_FILE_PATH); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to initialize the desktop backend output log at ${LOG_FILE_PATH}.`, + ); + assert.notInclude(error.message, "private setup diagnostic"); + }), + fileSystemLayer, + messages, + ); + }); + + it.effect("logs record write failures with the operation and exact cause", () => { + const messages: Array> = []; + const missingCause = PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "stat", + pathOrDescriptor: LOG_FILE_PATH, + }); + const writeCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFile", + pathOrDescriptor: LOG_FILE_PATH, + description: "private write diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.void, + stat: () => Effect.fail(missingCause), + readDirectory: () => Effect.succeed([]), + writeFile: () => Effect.fail(writeCause), + }); + + return withOutputLog( + Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); + + const error = loggedError(messages); + assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogWriteError); + assert.equal(error.operation, "write-record"); + assert.equal(error.logFilePath, LOG_FILE_PATH); + assert.strictEqual(error.cause, writeCause); + assert.equal( + error.message, + `Desktop backend output log operation "write-record" failed at ${LOG_FILE_PATH}.`, + ); + assert.notInclude(error.message, "private write diagnostic"); + }), + fileSystemLayer, + messages, + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.ts b/apps/desktop/src/app/DesktopBackendOutputLog.ts index ec29d54f44a..cad83229deb 100644 --- a/apps/desktop/src/app/DesktopBackendOutputLog.ts +++ b/apps/desktop/src/app/DesktopBackendOutputLog.ts @@ -19,8 +19,75 @@ 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; + readonly filePath: string; + readonly writeBytes: ( + chunk: Uint8Array, + ) => Effect.Effect; + readonly writeText: ( + chunk: string, + ) => Effect.Effect; +} + +class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterConfigurationError", + { + option: Schema.Literals(["maxBytes", "maxFiles"]), + value: Schema.Number, + }, +) { + override get message(): string { + return `${this.option} must be >= 1 (received ${this.value})`; + } +} + +class DesktopLogFileWriterRecoveryError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterRecoveryError", + { + logFilePath: Schema.String, + cause: Schema.Defect(), + recoveryCause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to refresh desktop backend output log size after a write failure at ${this.logFilePath}.`; + } +} + +export class DesktopBackendOutputLogSetupError extends Schema.TaggedErrorClass()( + "DesktopBackendOutputLogSetupError", + { + logFilePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the desktop backend output log at ${this.logFilePath}.`; + } +} + +export class DesktopBackendOutputLogWriteError extends Schema.TaggedErrorClass()( + "DesktopBackendOutputLogWriteError", + { + operation: Schema.Literals(["encode-record", "write-record"]), + logFilePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop backend output log operation "${this.operation}" failed at ${this.logFilePath}.`; + } +} + +export class DesktopBackendConsoleWriteError extends Schema.TaggedErrorClass()( + "DesktopBackendConsoleWriteError", + { + streamName: Schema.Literals(["stdout", "stderr"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to mirror desktop backend output to ${this.streamName}.`; + } } export class DesktopBackendOutputLog extends Context.Service< @@ -37,18 +104,6 @@ export class DesktopBackendOutputLog extends Context.Service< } >()("@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; @@ -85,10 +140,13 @@ const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").t const refreshFileSize = ( fileSystem: FileSystem.FileSystem, filePath: string, -): Effect.Effect => +): Effect.Effect => fileSystem.stat(filePath).pipe( Effect.map((stat) => Number(stat.size)), - Effect.orElseSucceed(() => 0), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(error), + }), ); const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { @@ -126,41 +184,52 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); const mutex = yield* Semaphore.make(1); + const recoverCurrentSize = ( + cause: PlatformError.PlatformError, + ): Effect.Effect => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.matchEffect({ + onFailure: (recoveryCause) => + Effect.fail( + new DesktopLogFileWriterRecoveryError({ + logFilePath: input.filePath, + cause, + recoveryCause, + }), + ), + onSuccess: (size) => Ref.set(currentSize, size).pipe(Effect.andThen(Effect.fail(cause))), + }), + ); + const pruneOverflowBackups = Effect.gen(function* () { - const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + const entries = yield* fileSystem.readDirectory(directory); for (const entry of entries) { if (!entry.startsWith(`${baseName}.`)) continue; const suffix = Number(entry.slice(baseName.length + 1)); if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; - yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(path.join(directory, entry), { force: true }); } }); const rotate = Effect.gen(function* () { - yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }); for (let index = maxFiles - 1; index >= 1; index -= 1) { const source = withSuffix(index); - const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); + const sourceExists = yield* fileSystem.exists(source); if (sourceExists) { yield* fileSystem.rename(source, withSuffix(index + 1)); } } - const currentExists = yield* fileSystem - .exists(input.filePath) - .pipe(Effect.orElseSucceed(() => false)); + const currentExists = yield* fileSystem.exists(input.filePath); 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 => { + const writeBytes = ( + chunk: Uint8Array, + ): Effect.Effect => { if (chunk.byteLength === 0) return Effect.void; return mutex.withPermits(1)( @@ -178,11 +247,9 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio yield* rotate; } }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), + Effect.catchTags({ + PlatformError: recoverCurrentSize, + }), ), ); }; @@ -190,6 +257,7 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio yield* pruneOverflowBackups; return { + filePath: input.filePath, writeBytes, writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), } satisfies RotatingLogFileWriter; @@ -199,10 +267,17 @@ 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); + Effect.try({ + try: () => { + const output = streamName === "stderr" ? process.stderr : process.stdout; + output.write(chunk); + }, + catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }), + }).pipe( + Effect.catchTags({ + DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }), + }), + ); const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( function* ( @@ -222,17 +297,47 @@ const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackend annotations: input.annotations, spans: {}, fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, - }); - yield* logFile.writeText(`${encoded}\n`); - }).pipe(Effect.ignore({ log: true })); + }).pipe( + Effect.mapError( + (cause) => + new DesktopBackendOutputLogWriteError({ + operation: "encode-record", + logFilePath: logFile.filePath, + cause, + }), + ), + ); + yield* logFile.writeText(`${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopBackendOutputLogWriteError({ + operation: "write-record", + logFilePath: logFile.filePath, + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + DesktopBackendOutputLogWriteError: (error) => Effect.logError(error.message, { error }), + }), + ); }, ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const logFilePath = environment.path.join(environment.logDir, "server-child.log"); const writer = yield* makeRotatingLogFileWriter({ - filePath: environment.path.join(environment.logDir, "server-child.log"), - }).pipe(Effect.option); + filePath: logFilePath, + }).pipe( + Effect.mapError((cause) => new DesktopBackendOutputLogSetupError({ logFilePath, cause })), + Effect.map(Option.some), + Effect.catchTags({ + DesktopBackendOutputLogSetupError: (error) => + Effect.logError(error.message, { error }).pipe(Effect.as(Option.none())), + }), + ); const service = Option.match(writer, { onNone: () => DesktopBackendOutputLogNoop, From d1339f38462c206d65d3c8d8396c1ddfdfbb807c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:45 -0700 Subject: [PATCH 184/257] [codex] Structure client state key errors (#3374) Co-authored-by: codex --- .../client-runtime/src/state/assets.test.ts | 28 ++++++++- packages/client-runtime/src/state/assets.ts | 32 +++++++++- .../client-runtime/src/state/entities.test.ts | 58 +++++++++++++++++++ packages/client-runtime/src/state/entities.ts | 50 +++++++++++++++- packages/client-runtime/src/state/threads.ts | 25 +------- 5 files changed, 164 insertions(+), 29 deletions(-) diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 1a4cf384663..58add31d6bb 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -4,7 +4,33 @@ import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; -import { createAssetEnvironmentAtoms } from "./assets.ts"; +import { + createAssetEnvironmentAtoms, + InvalidAssetCollectionKeyError, + parseAssetCollectionKey, +} from "./assets.ts"; + +describe("asset collection keys", () => { + it("preserves malformed JSON and its native cause", () => { + const key = "not-json"; + let error: unknown; + + try { + parseAssetCollectionKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(InvalidAssetCollectionKeyError); + expect(error).toMatchObject({ key, cause: expect.any(SyntaxError) }); + }); + + it("rejects invalid asset collection shapes", () => { + const key = JSON.stringify(["environment-1", [{ _tag: "unknown" }]]); + + expect(() => parseAssetCollectionKey(key)).toThrowError(InvalidAssetCollectionKeyError); + }); +}); describe("createAssetEnvironmentAtoms", () => { it("keys asset URL queries by environment and resource", () => { diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 6863de9055f..e407f5d0028 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,4 +1,5 @@ -import { EnvironmentId, type AssetResource, WS_METHODS } from "@t3tools/contracts"; +import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -8,6 +9,32 @@ const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; const ASSET_URL_STALE_TIME_MS = 5 * 60_000; const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; +export class InvalidAssetCollectionKeyError extends Schema.TaggedErrorClass()( + "InvalidAssetCollectionKeyError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid asset collection atom key: ${JSON.stringify(this.key)}.`; + } +} + +const decodeAssetCollectionKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]), +); + +export function parseAssetCollectionKey( + key: string, +): readonly [EnvironmentId, ReadonlyArray] { + try { + return decodeAssetCollectionKey(JSON.parse(key)); + } catch (cause) { + throw new InvalidAssetCollectionKeyError({ key, cause }); + } +} + export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { try { return new URL(relativeUrl, httpBaseUrl).toString(); @@ -27,8 +54,7 @@ export function createAssetEnvironmentAtoms( 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); + const [environmentId, resources] = parseAssetCollectionKey(key); return Atom.make((get) => resources.map((resource) => get( diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 2bdb8f84250..c772d134a67 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -11,6 +11,14 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { PrimaryConnectionTarget } from "../connection/model.ts"; +import { + InvalidScopedProjectKeyError, + InvalidScopedProjectRefCollectionKeyError, + InvalidScopedThreadKeyError, + parseProjectKey, + parseProjectRefCollectionKey, + parseThreadKey, +} from "./entities.ts"; import type { EnvironmentShellState } from "./shell.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; @@ -25,6 +33,56 @@ const OTHER_PROJECT_ID = ProjectId.make("project-2"); const THREAD_ID = ThreadId.make("thread-1"); const OTHER_THREAD_ID = ThreadId.make("thread-2"); +describe("scoped entity keys", () => { + it("preserves an invalid project key as structured error data", () => { + const key = "missing-project-key-separator"; + let error: unknown; + + try { + parseProjectKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toEqual(new InvalidScopedProjectKeyError({ key })); + }); + + it("preserves an invalid thread key as structured error data", () => { + const key = "missing-thread-key-separator"; + let error: unknown; + + try { + parseThreadKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toEqual(new InvalidScopedThreadKeyError({ key })); + }); + + it("preserves malformed project reference collection input and its cause", () => { + const key = "not-json"; + let error: unknown; + + try { + parseProjectRefCollectionKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(InvalidScopedProjectRefCollectionKeyError); + expect(error).toMatchObject({ key, cause: expect.anything() }); + }); + + it("rejects invalid project reference collection shapes", () => { + const key = JSON.stringify([["environment-1"]]); + + expect(() => parseProjectRefCollectionKey(key)).toThrowError( + InvalidScopedProjectRefCollectionKeyError, + ); + }); +}); + const THREAD_SHELL = { id: THREAD_ID, projectId: PROJECT_ID, diff --git a/packages/client-runtime/src/state/entities.ts b/packages/client-runtime/src/state/entities.ts index 4bcf16f7cfd..e90f31d6da4 100644 --- a/packages/client-runtime/src/state/entities.ts +++ b/packages/client-runtime/src/state/entities.ts @@ -5,6 +5,45 @@ import { type ScopedProjectRef, type ScopedThreadRef, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class InvalidScopedProjectKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedProjectKeyError", + { + key: Schema.String, + }, +) { + override get message(): string { + return `Invalid scoped project atom key: ${JSON.stringify(this.key)}.`; + } +} + +export class InvalidScopedThreadKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedThreadKeyError", + { + key: Schema.String, + }, +) { + override get message(): string { + return `Invalid scoped thread atom key: ${JSON.stringify(this.key)}.`; + } +} + +export class InvalidScopedProjectRefCollectionKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedProjectRefCollectionKeyError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid scoped project reference collection atom key: ${JSON.stringify(this.key)}.`; + } +} + +const decodeProjectRefCollectionKey = Schema.decodeUnknownSync( + Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +); export function projectKey(ref: ScopedProjectRef): string { return `${ref.environmentId}\u0000${ref.projectId}`; @@ -21,7 +60,7 @@ export function projectRefCollectionKey(refs: ReadonlyArray): export function parseProjectKey(key: string): ScopedProjectRef { const separator = key.indexOf("\u0000"); if (separator < 0) { - throw new Error("Invalid scoped project atom key."); + throw new InvalidScopedProjectKeyError({ key }); } return { environmentId: EnvironmentId.make(key.slice(0, separator)), @@ -30,7 +69,12 @@ export function parseProjectKey(key: string): ScopedProjectRef { } export function parseProjectRefCollectionKey(key: string): ReadonlyArray { - const entries = JSON.parse(key) as ReadonlyArray; + let entries: ReadonlyArray; + try { + entries = decodeProjectRefCollectionKey(JSON.parse(key)); + } catch (cause) { + throw new InvalidScopedProjectRefCollectionKeyError({ key, cause }); + } return entries.map(([environmentId, projectId]) => ({ environmentId: EnvironmentId.make(environmentId), projectId: ProjectId.make(projectId), @@ -40,7 +84,7 @@ export function parseProjectRefCollectionKey(key: string): ReadonlyArray( runtime: Atom.AtomRuntime, ) { const family = Atom.family((key: string) => { - const { environmentId, threadId } = parseThreadAtomKey(key); + const { environmentId, threadId } = parseThreadKey(key); return runtime .atom(threadStateChanges(environmentId, threadId), { initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, @@ -262,7 +243,7 @@ export function createEnvironmentThreadStateAtoms( return { stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => - family(threadAtomKey(environmentId, threadId)), + family(threadKey({ environmentId, threadId })), }; } From 50767683b396cf7065bb18072d6d14c05475b6e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:50 -0700 Subject: [PATCH 185/257] [codex] Preserve terminal preview link failure context (#3367) Co-authored-by: codex --- .../preview/openTerminalLinkInPreview.test.ts | 130 ++++++++++++++++++ .../preview/openTerminalLinkInPreview.ts | 50 ++++++- 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/preview/openTerminalLinkInPreview.test.ts diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts new file mode 100644 index 00000000000..47f03761f6b --- /dev/null +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -0,0 +1,130 @@ +import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + openTerminalLinkInPreview, + TerminalLinkContextMenuShowError, + TerminalLinkPreviewOpenError, +} from "./openTerminalLinkInPreview"; + +vi.mock("~/previewStateStore", () => ({ + applyPreviewServerSnapshot: vi.fn(), + isPreviewSupportedInRuntime: () => true, +})); + +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openBrowser: vi.fn() }), + }, +})); + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-20T00:00:00.000Z", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("openTerminalLinkInPreview", () => { + it("preserves context-menu failures with terminal link context before falling back", async () => { + const cause = new Error("menu unavailable"); + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://localhost:3000/path?token=secret", + position: { x: 12, y: 34 }, + threadRef, + openPreview, + localApi: { + contextMenu: { + show: vi.fn(async () => { + throw cause; + }), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(openPreview).not.toHaveBeenCalled(); + expect(reportError).toHaveBeenCalledOnce(); + const error = reportError.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError); + expect(error).toMatchObject({ + environmentId: "local", + threadId: "thread-1", + targetOrigin: "http://localhost:3000", + cause, + }); + expect(error.message).not.toContain("menu unavailable"); + expect(error.targetOrigin).not.toContain("secret"); + }); + + it("preserves the complete preview failure cause before falling back", async () => { + const rpcError = new Error("preview unavailable"); + const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect")); + const fallbackToBrowser = vi.fn(); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://127.0.0.1:5173/", + position: { x: 12, y: 34 }, + threadRef, + openPreview: async () => AsyncResult.failure(cause), + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(reportError).toHaveBeenCalledOnce(); + const error = reportError.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(TerminalLinkPreviewOpenError); + expect(error).toMatchObject({ + environmentId: "local", + threadId: "thread-1", + targetOrigin: "http://127.0.0.1:5173", + cause, + }); + expect(error.message).not.toContain("preview unavailable"); + }); + + it("does not report or fall back when opening the preview is interrupted", async () => { + const fallbackToBrowser = vi.fn(); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://localhost:5173/", + position: { x: 12, y: 34 }, + threadRef, + openPreview: async () => AsyncResult.failure(Cause.interrupt()), + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(reportError).not.toHaveBeenCalled(); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 216cce060e2..312eab9eb35 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -1,10 +1,37 @@ import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { isPreviewableUrl } from "@t3tools/shared/preview"; +import * as Schema from "effect/Schema"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +const terminalLinkErrorContext = { + environmentId: Schema.String, + threadId: Schema.String, + targetOrigin: Schema.String, + cause: Schema.Defect(), +}; + +export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass()( + "TerminalLinkContextMenuShowError", + terminalLinkErrorContext, +) { + override get message(): string { + return `Failed to show the context menu for terminal link ${this.targetOrigin}.`; + } +} + +export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass()( + "TerminalLinkPreviewOpenError", + terminalLinkErrorContext, +) { + override get message(): string { + return `Failed to open terminal link ${this.targetOrigin} in preview for thread ${this.threadId}.`; + } +} + interface OpenTerminalLinkInPreviewInput { readonly url: string; readonly position: { x: number; y: number }; @@ -27,6 +54,12 @@ export async function openTerminalLinkInPreview( return; } + const errorContext = { + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + targetOrigin: new URL(input.url).origin, + }; + let choice: "open-in-preview" | "open-in-browser" | null; try { choice = await input.localApi.contextMenu.show( @@ -36,7 +69,13 @@ export async function openTerminalLinkInPreview( ], input.position, ); - } catch { + } catch (cause) { + console.error( + new TerminalLinkContextMenuShowError({ + ...errorContext, + cause, + }), + ); input.fallbackToBrowser(); return; } @@ -47,6 +86,15 @@ export async function openTerminalLinkInPreview( input: { threadId: input.threadRef.threadId, url: input.url }, }); if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return; + } + console.error( + new TerminalLinkPreviewOpenError({ + ...errorContext, + cause: result.cause, + }), + ); input.fallbackToBrowser(); return; } From 58053d146b1ea7201884867d1543ccbbc4c05ffd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:54 -0700 Subject: [PATCH 186/257] [codex] Structure terminal PTY operation failures (#3364) Co-authored-by: codex --- apps/server/src/terminal/Manager.test.ts | 101 ++++++++++++++++++- apps/server/src/terminal/Manager.ts | 122 ++++++++++++++++------- packages/contracts/src/terminal.ts | 85 ++++++++++++---- 3 files changed, 251 insertions(+), 57 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c4c73ea7489..3a1cabc4a27 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -39,6 +39,8 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; readonly pid: number; + writeFailure: unknown | undefined; + resizeFailure: unknown | undefined; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); killed = false; @@ -48,10 +50,16 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } write(data: string): void { + if (this.writeFailure !== undefined) { + throw this.writeFailure; + } this.writes.push(data); } resize(cols: number, rows: number): void { + if (this.resizeFailure !== undefined) { + throw this.resizeFailure; + } this.resizeCalls.push({ cols, rows }); } @@ -435,6 +443,39 @@ it.layer( fs.writeFileString(filePath, contents), ); + it.effect("reports a missing cwd without an artificial cause", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "missing-cwd"); + const error = yield* Effect.flip(manager.open(openInput({ cwd }))); + + expect(error).toMatchObject({ + _tag: "TerminalCwdNotFoundError", + cwd, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("reports a cwd that is not a directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "cwd-file"); + yield* writeFileString(cwd, "not a directory"); + const error = yield* Effect.flip(manager.open(openInput({ cwd }))); + + expect(error).toMatchObject({ + _tag: "TerminalCwdNotDirectoryError", + cwd, + }); + expect("cause" in error).toBe(false); + }), + ); + it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; @@ -452,9 +493,11 @@ it.layer( ); expect(error).toMatchObject({ - _tag: "TerminalCwdError", + _tag: "TerminalCwdStatError", cwd: blockedCwd, - reason: "statFailed", + cause: { + _tag: "PlatformError", + }, }); }), ); @@ -498,6 +541,60 @@ it.layer( }), ); + it.effect("preserves structured context and causes for PTY I/O failures", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const writeCause = new Error("PTY input handle is unavailable"); + process.writeFailure = writeCause; + const writeError = yield* Effect.flip( + manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "secret input that must not be attached to the error", + }), + ); + + expect(writeError).toMatchObject({ + _tag: "TerminalWriteError", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + terminalPid: process.pid, + }); + expect(writeError.cause).toBe(writeCause); + expect(writeError).not.toHaveProperty("data"); + + const resizeCause = new Error("PTY resize handle is unavailable"); + process.resizeFailure = resizeCause; + const resizeError = yield* Effect.flip( + manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 132, + rows: 40, + }), + ); + + expect(resizeError).toMatchObject({ + _tag: "TerminalResizeError", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + terminalPid: process.pid, + cols: 132, + rows: 40, + }); + expect(resizeError.cause).toBe(resizeCause); + + process.resizeFailure = undefined; + yield* manager.open(openInput({ cols: 132, rows: 40 })); + expect(process.resizeCalls).toEqual([{ cols: 132, rows: 40 }]); + }), + ); + it.effect("ignores delayed resize requests after a terminal closes", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9fa9d07ebc9..6347fdfc64d 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -9,10 +9,15 @@ import { DEFAULT_TERMINAL_ID, TerminalCwdError, + TerminalCwdNotDirectoryError, + TerminalCwdNotFoundError, + TerminalCwdStatError, TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalResizeError, TerminalSessionLookupError, + TerminalWriteError, type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalClearInput, @@ -58,10 +63,15 @@ import * as PtyAdapter from "./PtyAdapter.ts"; export { TerminalCwdError, + TerminalCwdNotDirectoryError, + TerminalCwdNotFoundError, + TerminalCwdStatError, TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalResizeError, TerminalSessionLookupError, + TerminalWriteError, }; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; @@ -190,6 +200,25 @@ interface TerminalSubprocessInspector { ): Effect.Effect; } +const resizePtyProcess = ( + session: TerminalSessionState, + process: PtyAdapter.PtyProcess, + cols: number, + rows: number, +) => + Effect.try({ + try: () => process.resize(cols, rows), + catch: (cause) => + new TerminalResizeError({ + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid: process.pid, + cols, + rows, + cause, + }), + }); + export interface ShellCandidate { shell: string; args?: string[]; @@ -1157,16 +1186,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func 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 = ( @@ -1250,7 +1269,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId, terminalId, signal: "SIGTERM", - error: error.message, + cause: error, }).pipe(Effect.as(false)), ), ); @@ -1274,7 +1293,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId, terminalId, signal: "SIGKILL", - error: error.message, + cause: error, }), ), ); @@ -1372,16 +1391,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if ( yield* fileSystem .exists(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) + .pipe( + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ) ) { const raw = yield* fileSystem .readFileString(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ); const capped = capHistory(raw, historyLineLimit); if (capped !== raw) { yield* fileSystem .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }), + ), + ); } return capped; } @@ -1394,18 +1426,33 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if ( !(yield* fileSystem .exists(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + )) ) { return ""; } const raw = yield* fileSystem .readFileString(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); const capped = capHistory(raw, historyLineLimit); yield* fileSystem .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); yield* fileSystem.remove(legacyPath, { force: true }).pipe( Effect.catch((cleanupError) => Effect.logWarning("failed to remove legacy terminal history", { @@ -1472,20 +1519,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func 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, - }), - ), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? new TerminalCwdNotFoundError({ cwd }) + : new TerminalCwdStatError({ cwd, cause }), + }), ); if (stats.type !== "Directory") { - return yield* new TerminalCwdError({ - cwd, - reason: "notDirectory", - }); + return yield* new TerminalCwdNotDirectoryError({ cwd }); } }); @@ -1881,7 +1923,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* Effect.logError("failed to start terminal", { threadId: session.threadId, terminalId: session.terminalId, - error: message, + cause: error, ...(startedShell ? { shell: startedShell } : {}), }); } @@ -2168,10 +2210,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { + yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows); liveSession.cols = targetCols; liveSession.rows = targetRows; liveSession.updatedAt = yield* nowIso; - liveSession.process.resize(targetCols, targetRows); } return snapshot(liveSession); @@ -2219,10 +2261,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.status === "running" && (session.cols !== targetCols || session.rows !== targetRows) ) { + const process = session.process; + yield* resizePtyProcess(session, process, targetCols, targetRows); session.cols = targetCols; session.rows = targetRows; session.updatedAt = yield* nowIso; - yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); } return snapshot(session); @@ -2409,7 +2452,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } - yield* Effect.sync(() => process.write(input.data)); + yield* Effect.try({ + try: () => process.write(input.data), + catch: (cause) => + new TerminalWriteError({ + threadId: input.threadId, + terminalId, + terminalPid: process.pid, + cause, + }), + }); }); const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { @@ -2422,10 +2474,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (!process || session.value.status !== "running") { return; } + yield* resizePtyProcess(session.value, process, input.cols, input.rows); session.value.cols = input.cols; session.value.rows = input.rows; session.value.updatedAt = yield* nowIso; - yield* Effect.sync(() => process.resize(input.cols, input.rows)); }); const resize: TerminalManager["Service"]["resize"] = (input) => diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index a3c8e37e7f9..fa5f1821169 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -232,34 +232,47 @@ export const TerminalAttachStreamEvent = Schema.Union([ ]); export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type; -export class TerminalCwdError extends Schema.TaggedErrorClass()( - "TerminalCwdError", +export class TerminalCwdNotFoundError extends Schema.TaggedErrorClass()( + "TerminalCwdNotFoundError", + { + cwd: Schema.String, + }, +) { + override get message() { + return `Terminal cwd does not exist: ${this.cwd}`; + } +} + +export class TerminalCwdNotDirectoryError extends Schema.TaggedErrorClass()( + "TerminalCwdNotDirectoryError", { cwd: Schema.String, - reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]), - cause: Schema.optional(Schema.Defect()), }, ) { override get message() { - if (this.reason === "notDirectory") { - return `Terminal cwd is not a directory: ${this.cwd}`; - } - if (this.reason === "notFound") { - return `Terminal cwd does not exist: ${this.cwd}`; - } - const causeMessage = - this.cause !== undefined && - this.cause !== null && - typeof this.cause === "object" && - "message" in this.cause - ? this.cause.message - : undefined; - return typeof causeMessage === "string" && causeMessage.length > 0 - ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})` - : `Failed to access terminal cwd: ${this.cwd}`; + return `Terminal cwd is not a directory: ${this.cwd}`; } } +export class TerminalCwdStatError extends Schema.TaggedErrorClass()( + "TerminalCwdStatError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to access terminal cwd: ${this.cwd}`; + } +} + +export const TerminalCwdError = Schema.Union([ + TerminalCwdNotFoundError, + TerminalCwdNotDirectoryError, + TerminalCwdStatError, +]); +export type TerminalCwdError = typeof TerminalCwdError.Type; + export class TerminalHistoryError extends Schema.TaggedErrorClass()( "TerminalHistoryError", { @@ -298,10 +311,42 @@ export class TerminalNotRunningError extends Schema.TaggedErrorClass()( + "TerminalWriteError", + { + threadId: Schema.String, + terminalId: Schema.String, + terminalPid: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to write to terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid}`; + } +} + +export class TerminalResizeError extends Schema.TaggedErrorClass()( + "TerminalResizeError", + { + threadId: Schema.String, + terminalId: Schema.String, + terminalPid: Schema.Number, + cols: TerminalColsSchema, + rows: TerminalRowsSchema, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to resize terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid} to ${this.cols}x${this.rows}`; + } +} + export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, TerminalNotRunningError, + TerminalWriteError, + TerminalResizeError, ]); export type TerminalError = typeof TerminalError.Type; From b88873508e8dedbff772b9835604d66c8c72c17c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:59 -0700 Subject: [PATCH 187/257] [codex] Structure cross-client clipboard failures (#3361) Co-authored-by: codex --- apps/mobile/src/app/settings/environments.tsx | 4 +- .../connection/ConnectionEnvironmentRow.tsx | 2 +- .../EnvironmentConnectionNotice.tsx | 6 +- .../src/features/threads/ThreadFeed.tsx | 8 ++- .../mobile/src/lib/copyTextWithHaptic.test.ts | 60 +++++++++++++++- apps/mobile/src/lib/copyTextWithHaptic.ts | 69 +++++++++++++++++- apps/web/src/components/PlanSidebar.tsx | 2 +- .../src/components/chat/ProposedPlanCard.tsx | 1 + .../settings/DiagnosticsSettings.tsx | 17 ++--- apps/web/src/components/ui/toast.tsx | 2 +- apps/web/src/hooks/useCopyToClipboard.test.ts | 58 +++++++++++++++ apps/web/src/hooks/useCopyToClipboard.ts | 71 +++++++++++++++---- 12 files changed, 261 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/hooks/useCopyToClipboard.test.ts diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index c09bb3cebe6..8f65c630a54 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -397,7 +397,7 @@ function CloudEnvironmentRowShell(props: { className={cn("text-xs leading-[16px] underline", statusClassName)} onLongPress={(event) => { event.stopPropagation(); - copyTextWithHaptic(errorTraceId); + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); }} onPress={(event) => { event.stopPropagation(); @@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) { { - copyTextWithHaptic(props.traceId); + copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); }} className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" > diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index f5aa26be960..7b901ec4c66 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -101,7 +101,7 @@ export function ConnectionEnvironmentRow(props: { className="underline" onLongPress={(event) => { event.stopPropagation(); - copyTextWithHaptic(statusTraceId); + copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); }} onPress={(event) => { event.stopPropagation(); diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index 9b8c96d25ea..373b0d3ef03 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: { accessibilityHint="Copies the trace ID" accessibilityRole="button" className="underline decoration-dotted" - onPress={() => copyTextWithHaptic(props.connection.traceId!)} + onPress={() => + copyTextWithHaptic(props.connection.traceId!, { + target: "connection-trace-id", + }) + } > {props.connection.traceId} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7424d856368..4f8ca9c747d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,4 +1,3 @@ -import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; @@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { - void Clipboard.setStringAsync(value); - void Haptics.selectionAsync(); + copyTextWithHaptic(value, { + target: "thread-work-row", + feedback: "selection", + }); setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts index d15a3a1a59b..236fb44cd6b 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ impactAsync: vi.fn(), + selectionAsync: vi.fn(), setStringAsync: vi.fn(), })); @@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({ Light: "light", }, impactAsync: mocks.impactAsync, + selectionAsync: mocks.selectionAsync, })); -import { copyTextWithHaptic } from "./copyTextWithHaptic"; +import { + CopyTextClipboardWriteError, + CopyTextHapticFeedbackError, + copyTextWithHaptic, +} from "./copyTextWithHaptic"; describe("copyTextWithHaptic", () => { beforeEach(() => { vi.clearAllMocks(); mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); mocks.impactAsync.mockResolvedValue(undefined); + mocks.selectionAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("triggers haptic feedback without waiting for the clipboard promise", () => { @@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => { expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); expect(mocks.impactAsync).toHaveBeenCalledWith("light"); }); + + it("preserves selection feedback for thread work rows", () => { + copyTextWithHaptic("work output", { + target: "thread-work-row", + feedback: "selection", + }); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("work output"); + expect(mocks.selectionAsync).toHaveBeenCalledOnce(); + expect(mocks.impactAsync).not.toHaveBeenCalled(); + }); + + it("reports structured failures without including clipboard contents", async () => { + const clipboardCause = new Error("native clipboard failure"); + const hapticCause = new Error("native haptic failure"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); + mocks.impactAsync.mockRejectedValueOnce(hapticCause); + + copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" }); + + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledTimes(2); + }); + + const failures = consoleError.mock.calls.map(([failure]) => failure); + const clipboardError = failures.find( + (failure) => failure instanceof CopyTextClipboardWriteError, + ); + expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError); + expect(clipboardError).toMatchObject({ + target: "connection-trace-id", + cause: clipboardCause, + }); + expect((clipboardError as Error).message).not.toContain("secret clipboard contents"); + + const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError); + expect(hapticError).toBeInstanceOf(CopyTextHapticFeedbackError); + expect(hapticError).toMatchObject({ + target: "connection-trace-id", + feedback: "light-impact", + cause: hapticCause, + }); + expect((hapticError as Error).message).not.toContain("secret clipboard contents"); + }); }); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts index 80f725f5b00..1cc8c94eef7 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -1,7 +1,70 @@ +import * as Schema from "effect/Schema"; import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; -export function copyTextWithHaptic(value: string): void { - void Clipboard.setStringAsync(value); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +export class CopyTextClipboardWriteError extends Schema.TaggedErrorClass()( + "CopyTextClipboardWriteError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.target} to the clipboard.`; + } +} + +export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass()( + "CopyTextHapticFeedbackError", + { + target: Schema.String, + feedback: Schema.Literals(["light-impact", "selection"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to trigger ${this.feedback} haptic feedback after copying ${this.target}.`; + } +} + +export function copyTextWithHaptic( + value: string, + options: { + readonly target?: string; + readonly feedback?: "light-impact" | "selection"; + } = {}, +): void { + const target = options.target ?? "text"; + const feedback = options.feedback ?? "light-impact"; + + void (async () => { + try { + await Clipboard.setStringAsync(value); + } catch (cause) { + console.error( + new CopyTextClipboardWriteError({ + target, + cause, + }), + ); + } + })(); + + void (async () => { + try { + if (feedback === "selection") { + await Haptics.selectionAsync(); + } else { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + } catch (cause) { + console.error( + new CopyTextHapticFeedbackError({ + target, + feedback, + cause, + }), + ); + } + })(); } diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index fec255355a5..abc0db79b6c 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({ const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { reportFailure: false, }); - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index e507a2f7709..9746857a8ca 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -54,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ reportFailure: false, }); const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "plan", onError: (error) => { toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 6df3367c642..92d8d3f6827 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -32,6 +32,7 @@ import { } from "../../state/server"; import { shellEnvironment } from "../../state/shell"; import { usePrimaryEnvironment } from "../../state/environments"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -246,16 +247,10 @@ function DiagnosticsTable({ } function TraceIdCell({ traceId }: { traceId: string }) { - const [copied, setCopied] = useState(false); - const copyTraceId = useCallback(() => { - void navigator.clipboard - ?.writeText(traceId) - .then(() => { - setCopied(true); - window.setTimeout(() => setCopied(false), 1_200); - }) - .catch(() => undefined); - }, [traceId]); + const { copyToClipboard, isCopied: copied } = useCopyToClipboard({ + target: "trace ID", + timeout: 1_200, + }); return (

@@ -281,7 +276,7 @@ function TraceIdCell({ traceId }: { traceId: string }) { type="button" className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" aria-label={copied ? "Copied trace ID" : "Copy trace ID"} - onClick={copyTraceId} + onClick={() => copyToClipboard(traceId)} > diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 2c2a554871a..d48cda453b8 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -117,7 +117,7 @@ function handleToastDismissClick( } function CopyErrorButton({ text }: { text: string }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" }); const label = isCopied ? "Copied error" : "Copy error"; return ( diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts new file mode 100644 index 00000000000..ccac333cd48 --- /dev/null +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + ClipboardApiUnavailableError, + ClipboardWriteError, + writeTextToClipboard, +} from "./useCopyToClipboard"; + +describe("writeTextToClipboard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reports unavailable clipboard support with structural context", async () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + + const error = await writeTextToClipboard("plan contents", "plan").then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(ClipboardApiUnavailableError); + expect(error).toMatchObject({ + target: "plan", + }); + expect((error as Error).message).not.toContain("plan contents"); + }); + + it("preserves the exact clipboard failure without exposing copied contents", async () => { + const cause = new Error("browser clipboard failure"); + const writeText = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + const error = await writeTextToClipboard("secret clipboard contents", "error-message").then( + () => undefined, + (failure: unknown) => failure, + ); + + expect(writeText).toHaveBeenCalledWith("secret clipboard contents"); + expect(error).toBeInstanceOf(ClipboardWriteError); + expect(error).toMatchObject({ + target: "error-message", + cause, + }); + expect((error as Error).message).not.toContain("secret clipboard contents"); + }); + + it("keeps empty values as a no-op when clipboard support is available", async () => { + const writeText = vi.fn(); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index d1feb621159..0129f2d6593 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -1,11 +1,61 @@ import * as React from "react"; +import * as Schema from "effect/Schema"; + +export class ClipboardApiUnavailableError extends Schema.TaggedErrorClass()( + "ClipboardApiUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while copying ${this.target}.`; + } +} + +export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardWriteError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.target} to the clipboard.`; + } +} + +export async function writeTextToClipboard(value: string, target = "text") { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.writeText + ) { + throw new ClipboardApiUnavailableError({ + target, + }); + } + + if (!value) return false; + + try { + await navigator.clipboard.writeText(value); + return true; + } catch (cause) { + throw new ClipboardWriteError({ + target, + cause, + }); + } +} export function useCopyToClipboard({ timeout = 2000, + target = "text", onCopy, onError, }: { timeout?: number; + target?: string; onCopy?: (ctx: TContext) => void; onError?: (error: Error, ctx: TContext) => void; } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { @@ -13,22 +63,18 @@ export function useCopyToClipboard({ const timeoutIdRef = React.useRef(null); const onCopyRef = React.useRef(onCopy); const onErrorRef = React.useRef(onError); + const targetRef = React.useRef(target); const timeoutRef = React.useRef(timeout); onCopyRef.current = onCopy; onErrorRef.current = onError; + targetRef.current = target; timeoutRef.current = timeout; const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx); - return; - } - - if (!value) return; - - navigator.clipboard.writeText(value).then( - () => { + void writeTextToClipboard(value, targetRef.current).then( + (didCopy) => { + if (!didCopy) return; if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); } @@ -44,11 +90,8 @@ export function useCopyToClipboard({ } }, (error) => { - if (onErrorRef.current) { - onErrorRef.current(error, ctx); - } else { - console.error(error); - } + console.error(error); + onErrorRef.current?.(error, ctx); }, ); }, []); From d7718e81e0af33d0a2d0f515385ac210f352fffb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:13:04 -0700 Subject: [PATCH 188/257] [codex] Structure mobile notification navigation failures (#3359) Co-authored-by: codex --- .../notificationNavigation.test.ts | 75 ++++++++++++++++++- .../agent-awareness/notificationNavigation.ts | 15 ++-- .../notificationResponseConsumer.ts | 58 ++++++++++++++ 3 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 6d7c247dfad..2dd3ca03de2 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { NotificationResponse } from "expo-notifications"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; import { extractAgentNotificationDeepLink, @@ -18,6 +21,76 @@ function responseWithData(data: Record, identifier = "notificat }; } +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("consumeLastAgentNotificationResponse", () => { + it("reports which initial-response operation failed", async () => { + const cause = new Error("notification lookup unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.reject(cause), + clearLastResponse: () => Promise.resolve(), + handleResponse: vi.fn(), + }); + + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "read", + }), + ); + }); + + it("routes a response before reporting a clear failure", async () => { + const cause = new Error("notification clear unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({}, "notification-clear") as NotificationResponse; + const handleResponse = vi.fn(); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse: () => Promise.reject(cause), + handleResponse, + }); + + expect(handleResponse).toHaveBeenCalledWith(response); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "clear", + notificationId: "notification-clear", + }), + ); + }); + + it("reports routing failures before clearing the response", async () => { + const cause = new Error("notification routing unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({}, "notification-route") as NotificationResponse; + const clearLastResponse = vi.fn(() => Promise.resolve()); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: () => { + throw cause; + }, + }); + + expect(clearLastResponse).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "route", + notificationId: "notification-route", + }), + ); + }); +}); + describe("extractAgentNotificationDeepLink", () => { it("uses explicit deep links from APNs payload data", () => { expect( diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index a7027623653..18bb93d723e 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -3,6 +3,7 @@ import * as Notifications from "expo-notifications"; import { useRouter } from "expo-router"; import { routeAgentNotificationResponseOnce } from "./notificationPayload"; +import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; export function useAgentNotificationNavigation(): void { const router = useRouter(); @@ -18,15 +19,11 @@ export function useAgentNotificationNavigation(): void { }; const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); - void Notifications.getLastNotificationResponseAsync() - .then((response) => { - if (response) { - handleResponse(response); - return Notifications.clearLastNotificationResponseAsync(); - } - return undefined; - }) - .catch(() => undefined); + void consumeLastAgentNotificationResponse({ + getLastResponse: () => Notifications.getLastNotificationResponseAsync(), + clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(), + handleResponse, + }); return () => { subscription.remove(); diff --git a/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts b/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts new file mode 100644 index 00000000000..be6bfa820fa --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts @@ -0,0 +1,58 @@ +import type { NotificationResponse } from "expo-notifications"; +import * as Schema from "effect/Schema"; + +export class NotificationNavigationError extends Schema.TaggedErrorClass()( + "NotificationNavigationError", + { + operation: Schema.Literals(["read", "route", "clear"]), + notificationId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} the last notification response.`; + } +} + +export async function consumeLastAgentNotificationResponse(input: { + readonly getLastResponse: () => Promise; + readonly clearLastResponse: () => Promise; + readonly handleResponse: (response: NotificationResponse) => void; +}): Promise { + let response: NotificationResponse | null; + try { + response = await input.getLastResponse(); + } catch (cause) { + console.error(new NotificationNavigationError({ operation: "read", cause })); + return; + } + + if (!response) { + return; + } + + try { + input.handleResponse(response); + } catch (cause) { + console.error( + new NotificationNavigationError({ + operation: "route", + notificationId: response.notification.request.identifier, + cause, + }), + ); + return; + } + + try { + await input.clearLastResponse(); + } catch (cause) { + console.error( + new NotificationNavigationError({ + operation: "clear", + notificationId: response.notification.request.identifier, + cause, + }), + ); + } +} From 97de7d7e1535ddf485ea8806c639f27e99cfbcb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:13:48 -0700 Subject: [PATCH 189/257] [codex] Structure agent awareness registration errors (#3328) Co-authored-by: codex --- .../remoteRegistration.test.ts | 23 ++++ .../agent-awareness/remoteRegistration.ts | 107 ++++++++++++++---- 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 43d62b81622..7f97d7c718c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -18,6 +18,7 @@ import { cryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { + AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, @@ -267,6 +268,28 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }); + it.effect("preserves Live Activity push-token lookup failures", () => { + const cause = new Error("native token lookup failed"); + const activity = { + getPushToken: vi.fn(() => Promise.reject(cause)), + addPushTokenListener: vi.fn(), + }; + + return Effect.gen(function* () { + const error = yield* Effect.flip( + registerLiveActivityPushToken({ activity: activity as never }), + ); + + expect(error).toBeInstanceOf(AgentAwarenessOperationError); + expect(error).toMatchObject({ + _tag: "AgentAwarenessOperationError", + operation: "read-live-activity-push-token", + cause, + message: "Agent awareness operation read-live-activity-push-token failed.", + }); + }).pipe(Effect.provide(relayTestLayer)); + }); + it.effect( "reports Live Activity token registration as skipped when relay auth is unavailable", () => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 98e38c74055..3281381e0e1 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -2,6 +2,7 @@ import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { @@ -28,6 +29,33 @@ import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; + +const AgentAwarenessOperation = Schema.Literals([ + "read-notification-permissions", + "read-native-push-token", + "read-device-registration-relay-token", + "read-device-unregistration-relay-token", + "read-live-activity-registration-relay-token", + "load-device-registration-identifier", + "load-device-registration-preferences", + "load-device-unregistration-identifier", + "read-live-activity-push-token", + "load-live-activity-registration-identifier", + "list-active-live-activities", +]); + +export class AgentAwarenessOperationError extends Schema.TaggedErrorClass()( + "AgentAwarenessOperationError", + { + operation: AgentAwarenessOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Agent awareness operation ${this.operation} failed.`; + } +} + const environmentConnections = new Map(); const activityPushTokenListeners = new WeakSet>(); let pushToStartSubscription: { remove: () => void } | null = null; @@ -137,14 +165,22 @@ function nativePushTokenRegistration(observedPushToken?: string) { } const permissions = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-notification-permissions", + cause, + }), }); if (!permissions.granted) { return { notificationsEnabled: false, pushToken: null }; } const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-native-push-token", + cause, + }), }).pipe( Effect.tapError((error) => Effect.sync(() => { @@ -161,16 +197,19 @@ function nativePushTokenRegistration(observedPushToken?: string) { }); } -const relayToken = Effect.gen(function* () { - const provider = relayTokenProvider; - if (!provider) { - return null; - } - return yield* Effect.tryPromise({ - try: provider, - catch: (error) => error, +const relayToken = ( + operation: "read-device-registration-relay-token" | "read-live-activity-registration-relay-token", +) => + Effect.gen(function* () { + const provider = relayTokenProvider; + if (!provider) { + return null; + } + return yield* Effect.tryPromise({ + try: provider, + catch: (cause) => new AgentAwarenessOperationError({ operation, cause }), + }); }); -}); function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, @@ -185,7 +224,7 @@ function registerDeviceWithRelay( return; } if (!readRelayConfig()) return; - const token = yield* relayToken; + const token = yield* relayToken("read-device-registration-relay-token"); if (expectedGeneration !== deviceRegistrationGeneration) { logRegistrationDebug("device registration cancelled after auth lookup", { expectedGeneration, @@ -220,7 +259,11 @@ function unregisterDeviceWithRelay(input: { if (!readRelayConfig()) return; const token = yield* Effect.tryPromise({ try: input.tokenProvider, - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-device-unregistration-relay-token", + cause, + }), }); if (!token) { logRegistrationDebug("relay device unregistration skipped; user is not signed in"); @@ -240,7 +283,7 @@ function registerLiveActivityWithRelay( ): Effect.Effect { return Effect.gen(function* () { if (!readRelayConfig()) return false; - const token = yield* relayToken; + const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { logRegistrationDebug("relay live activity registration skipped; user is not signed in"); return false; @@ -381,11 +424,19 @@ function registerDevice( const [deviceId, preferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-identifier", + cause, + }), }), Effect.tryPromise({ try: () => loadPreferences(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-preferences", + cause, + }), }), ]); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); @@ -519,7 +570,11 @@ export function unregisterAgentAwarenessDeviceForCurrentUser( return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-unregistration-identifier", + cause, + }), }); if (!deviceId) { return; @@ -544,7 +599,11 @@ export function registerLiveActivityPushToken(input: { const activityPushToken = yield* Effect.tryPromise({ try: () => input.activity.getPushToken(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-live-activity-push-token", + cause, + }), }); if (!activityPushToken) { if (activityPushTokenListeners.has(input.activity)) { @@ -592,7 +651,11 @@ function registerLiveActivityPushTokenValue(input: { return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-live-activity-registration-identifier", + cause, + }), }); const registered = yield* registerLiveActivityWithRelay({ deviceId, @@ -633,7 +696,11 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< const activities = yield* Effect.try({ try: () => AgentActivity.getInstances(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "list-active-live-activities", + cause, + }), }).pipe( Effect.catch((error) => Effect.sync(() => { From a51691a9b0a98e65f0e9b2b775b22a2382595e87 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:13:53 -0700 Subject: [PATCH 190/257] [codex] Structure server environment-label probe failures (#3321) Co-authored-by: codex --- .../ServerEnvironmentLabel.test.ts | 146 +++++++++++++++--- .../src/environment/ServerEnvironmentLabel.ts | 130 +++++++++++++--- 2 files changed, 235 insertions(+), 41 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts index 4bc9647fba5..b5bb8a8ff1c 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -2,12 +2,28 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; -import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; +import * as ServerEnvironmentLabel from "./ServerEnvironmentLabel.ts"; + +const isServerEnvironmentLabelFileError = Schema.is( + ServerEnvironmentLabel.ServerEnvironmentLabelFileError, +); +const isServerEnvironmentLabelCommandError = Schema.is( + ServerEnvironmentLabel.ServerEnvironmentLabelCommandError, +); + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} const runMock = vi.fn(); @@ -47,7 +63,7 @@ afterEach(() => { describe("resolveServerEnvironmentLabel", () => { it.effect("uses hostname fallback regardless of launch mode", () => Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); @@ -68,7 +84,7 @@ describe("resolveServerEnvironmentLabel", () => { }), ); - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); @@ -85,7 +101,7 @@ describe("resolveServerEnvironmentLabel", () => { it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); @@ -107,7 +123,7 @@ describe("resolveServerEnvironmentLabel", () => { }), ); - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); @@ -124,7 +140,7 @@ describe("resolveServerEnvironmentLabel", () => { it.effect("falls back to the hostname when friendly labels are unavailable", () => Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); @@ -132,25 +148,111 @@ describe("resolveServerEnvironmentLabel", () => { }), ); - it.effect("falls back to the hostname when the friendly-label command is missing", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: "scutil", - argumentCount: 2, - cause: new Error("spawn scutil ENOENT"), - }), - ), - ); + it.effect("falls back to the hostname when the friendly-label command is missing", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + const spawnCause = new Error("spawn scutil ENOENT"); + const processError = new ProcessRunner.ProcessSpawnError({ + command: "scutil", + argumentCount: 2, + cause: spawnCause, + }); + runMock.mockReturnValueOnce(Effect.fail(processError)); - const result = yield* resolveServerEnvironmentLabel({ + return Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); + }); expect(result).toBe("macbook-pro"); - }), - ); + expect(logs[0]?.message).toEqual([ + "Failed to run environment-label probe 'macos-computer-name' with scutil.", + ]); + const error = logs[0]?.annotations.cause; + expect(isServerEnvironmentLabelCommandError(error)).toBe(true); + if (isServerEnvironmentLabelCommandError(error)) { + expect(error.probe).toBe("macos-computer-name"); + expect(error.executable).toBe("scutil"); + expect(error.argumentCount).toBe(2); + expect(error).not.toHaveProperty("args"); + expect(error.message).not.toContain("--get"); + expect(error.message).not.toContain("ComputerName"); + expect(error.cause).toBe(processError); + expect(processError.cause).toBe(spawnCause); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + withHostPlatform(TestLayer, "darwin", "macbook-pro"), + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); + + it.effect("continues to hostnamectl after a machine-info inspect failure", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + const fileCause = new Error("permission denied"); + const platformError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: "/etc/machine-info", + cause: fileCause, + }); + const fileSystemLayer = FileSystem.layerNoop({ + exists: () => Effect.fail(platformError), + }); + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: "CI Runner\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + return Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }); + + expect(result).toBe("CI Runner"); + expect(logs[0]?.message).toEqual([ + "Failed to inspect environment-label file at /etc/machine-info.", + ]); + const error = logs[0]?.annotations.cause; + expect(isServerEnvironmentLabelFileError(error)).toBe(true); + if (isServerEnvironmentLabelFileError(error)) { + expect(error.operation).toBe("inspect"); + expect(error.path).toBe("/etc/machine-info"); + expect(error.cause).toBe(platformError); + expect(platformError.cause).toBe(fileCause); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + withHostPlatform(Layer.merge(ProcessRunnerTest, fileSystemLayer), "linux", "buildbox"), + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); it.effect("falls back to the cwd basename when the hostname is blank", () => Effect.gen(function* () { @@ -165,7 +267,7 @@ describe("resolveServerEnvironmentLabel", () => { }), ); - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts index 83c3b8bad8e..bd034e0fa26 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.ts @@ -2,6 +2,7 @@ import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostPr import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as ProcessRunner from "../processRunner.ts"; @@ -9,6 +10,39 @@ interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; } +const ServerEnvironmentLabelCommandProbe = Schema.Literals([ + "macos-computer-name", + "linux-pretty-hostname", +]); +type ServerEnvironmentLabelCommandProbe = typeof ServerEnvironmentLabelCommandProbe.Type; + +export class ServerEnvironmentLabelFileError extends Schema.TaggedErrorClass()( + "ServerEnvironmentLabelFileError", + { + operation: Schema.Literals(["inspect", "read"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} environment-label file at ${this.path}.`; + } +} + +export class ServerEnvironmentLabelCommandError extends Schema.TaggedErrorClass()( + "ServerEnvironmentLabelCommandError", + { + probe: ServerEnvironmentLabelCommandProbe, + executable: Schema.String, + argumentCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to run environment-label probe '${this.probe}' with ${this.executable}.`; + } +} + function normalizeLabel(value: string | null | undefined): string | null { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : null; @@ -34,30 +68,80 @@ function parseMachineInfoValue(raw: string, key: string): string | null { const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fileSystem - .exists("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return null; - } - - return yield* fileSystem - .readFileString("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => null)); + const machineInfoPath = "/etc/machine-info"; + return yield* fileSystem.exists(machineInfoPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelFileError({ + operation: "inspect", + path: machineInfoPath, + cause, + }), + ), + Effect.flatMap((exists) => + exists + ? fileSystem.readFileString(machineInfoPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelFileError({ + operation: "read", + path: machineInfoPath, + cause, + }), + ), + ) + : Effect.succeed(null), + ), + Effect.catchTags({ + ServerEnvironmentLabelFileError: (error) => + Effect.logDebug(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + path: error.path, + cause: error, + }), + Effect.as(null), + ), + }), + ); }); -const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( - command: string, - args: readonly string[], -) { +const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* (input: { + readonly probe: ServerEnvironmentLabelCommandProbe; + readonly command: string; + readonly args: readonly string[]; +}) { const processRunner = yield* ProcessRunner.ProcessRunner; const result = yield* processRunner .run({ - command, - args, + command: input.command, + args: input.args, timeoutBehavior: "timedOutResult", }) - .pipe(Effect.option); + .pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelCommandError({ + probe: input.probe, + executable: input.command, + argumentCount: input.args.length, + cause, + }), + ), + Effect.map(Option.some), + Effect.catchTags({ + ServerEnvironmentLabelCommandError: (error) => + Effect.logDebug(error.message).pipe( + Effect.annotateLogs({ + probe: error.probe, + executable: error.executable, + argumentCount: error.argumentCount, + cause: error, + }), + Effect.as(Option.none()), + ), + }), + ); if (Option.isNone(result) || result.value.code !== 0) { return null; @@ -69,7 +153,11 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { const platform = yield* HostProcessPlatform; if (platform === "darwin") { - return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); + return yield* runFriendlyLabelCommand({ + probe: "macos-computer-name", + command: "scutil", + args: ["--get", "ComputerName"], + }); } if (platform === "linux") { @@ -81,7 +169,11 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* } } - return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); + return yield* runFriendlyLabelCommand({ + probe: "linux-pretty-hostname", + command: "hostnamectl", + args: ["--pretty"], + }); } return null; From a76b7bbfb5ad1e87714d85516e68bda594f9f830 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:02 -0700 Subject: [PATCH 191/257] Preserve PortScanner probe defects (#3282) Co-authored-by: codex --- apps/server/src/preview/PortScanner.test.ts | 72 ++++++++++++++++++++- apps/server/src/preview/PortScanner.ts | 26 +++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 6c48f6d5c8b..69b5729164d 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -3,14 +3,48 @@ import * as NodeNet from "node:net"; import { it as effectIt } from "@effect/vitest"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; -import { Effect, Layer } from "effect"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), + run: (input) => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cwd: input.cwd, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "PowerShell is not installed in the test environment", + }), + }), + ), }); + +const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) => + PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProcessRunner.ProcessRunner, { run }), + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }), + Layer.succeed(HostProcessPlatform, "linux"), + ), + ), + ); + const TestPortDiscoveryLive = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), @@ -87,3 +121,37 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall }), ); }); + +effectIt("does not swallow process probe defects", () => + Effect.gen(function* () { + const defect = new Error("unexpected process probe defect"); + const layer = makeProbeFailureLayer(() => Effect.die(defect)); + + const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); + } + }), +); + +effectIt("does not swallow process probe interruption", () => + Effect.gen(function* () { + const layer = makeProbeFailureLayer(() => Effect.interrupt); + + const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 16ff0fed58f..c306fca2b33 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -221,6 +221,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() { })); }); + const recoverProcessProbeFailure = + (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => + Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + cause: error, + probe, + platform: hostPlatform, + }).pipe(Effect.as(null)); + const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map(); @@ -230,6 +238,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } } if (hostPlatform === "win32") { + const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); const command = 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; const listeners = yield* processRunner @@ -242,11 +251,18 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), - Effect.catchCause(() => Effect.succeed(null)), + Effect.catchTags({ + ProcessSpawnError: recoverWindowsProbeFailure, + ProcessStdinError: recoverWindowsProbeFailure, + ProcessOutputLimitError: recoverWindowsProbeFailure, + ProcessReadError: recoverWindowsProbeFailure, + ProcessTimeoutError: recoverWindowsProbeFailure, + }), ); if (listeners !== null) return listeners; return yield* probeCommonPorts(); } + const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); const lsofResult = yield* processRunner .run({ command: "lsof", @@ -257,7 +273,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), - Effect.catchCause(() => Effect.succeed(null)), + Effect.catchTags({ + ProcessSpawnError: recoverLsofProbeFailure, + ProcessStdinError: recoverLsofProbeFailure, + ProcessOutputLimitError: recoverLsofProbeFailure, + ProcessReadError: recoverLsofProbeFailure, + ProcessTimeoutError: recoverLsofProbeFailure, + }), ); if (lsofResult !== null) return lsofResult; return yield* probeCommonPorts(); From aab72f10c1227b7bbf9cce76644e374e275a4e1e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:41 -0700 Subject: [PATCH 192/257] [codex] Report markdown interaction failures (#3355) Co-authored-by: codex --- apps/web/src/components/ChatMarkdown.tsx | 263 +++++++++++++++-------- 1 file changed, 174 insertions(+), 89 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1d8920e4812..711a545d90a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -120,6 +120,20 @@ const EMPTY_MARKDOWN_SKILLS: ReadonlyArray( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, @@ -332,7 +346,9 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { copiedTimerRef.current = null; }, 1200); }) - .catch(() => undefined); + .catch((cause) => { + reportMarkdownActionFailure({ operation: "copy-table", format }, cause); + }); }, []); useEffect( @@ -529,8 +545,17 @@ function MarkdownCodeBlock({ copiedTimerRef.current = null; }, 1200); }) - .catch(() => undefined); - }, [code]); + .catch((cause) => { + reportMarkdownActionFailure( + { + operation: "copy-code-block", + language, + ...(fenceTitle ? { fenceTitle } : {}), + }, + cause, + ); + }); + }, [code, fenceTitle, language]); useEffect( () => () => { @@ -977,18 +1002,36 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { void (async () => { - const result = await onOpen(targetPath); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; + try { + const result = await onOpen(targetPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "open-file-in-editor", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "open-file-in-editor", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); })(); }, [onOpen, targetPath]); @@ -1005,52 +1048,77 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } void (async () => { - const result = await onOpenInBrowser(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; + try { + const result = await onOpenInBrowser(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "open-file-in-browser", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "open-file-in-browser", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); })(); - }, [onOpenInBrowser]); - - const handleCopy = useCallback((value: string, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); - return; - } + }, [onOpenInBrowser, targetPath]); - void navigator.clipboard.writeText(value).then( - () => { - toastManager.add({ - type: "success", - title: `${title} copied`, - description: value, - }); - }, - (error) => { + const handleCopy = useCallback( + (value: string, title: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ type: "error", title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", + description: "Clipboard API unavailable.", }), ); - }, - ); - }, []); + return; + } + + void navigator.clipboard.writeText(value).then( + () => { + toastManager.add({ + type: "success", + title: `${title} copied`, + description: value, + }); + }, + (error) => { + reportMarkdownActionFailure( + { operation: "copy-file-path", target: targetPath, copyTarget: title }, + error, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }, + [targetPath], + ); const handleContextMenu = useCallback( async (event: ReactMouseEvent) => { @@ -1060,32 +1128,39 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "open", label: "Open in editor" }, - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - { id: "copy-relative", label: "Copy relative path" }, - { id: "copy-full", label: "Copy full path" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); + try { + const clicked = await api.contextMenu.show( + [ + { id: "open", label: "Open in editor" }, + ...(onOpenInBrowser + ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + : []), + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); - if (clicked === "open") { - handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { - handleOpenInBrowser(); - return; - } - if (clicked === "copy-relative") { - handleCopy(displayPath, "Relative path"); - return; - } - if (clicked === "copy-full") { - handleCopy(targetPath, "Full path"); + if (clicked === "open") { + handleOpenInEditor(); + return; + } + if (clicked === "open-in-browser") { + handleOpenInBrowser(); + return; + } + if (clicked === "copy-relative") { + handleCopy(displayPath, "Relative path"); + return; + } + if (clicked === "copy-full") { + handleCopy(targetPath, "Full path"); + } + } catch (cause) { + reportMarkdownActionFailure( + { operation: "show-file-context-menu", target: targetPath }, + cause, + ); } }, [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], @@ -1315,22 +1390,32 @@ function ChatMarkdown({ 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) => { + void (async () => { + let operation = "show-link-context-menu"; + try { + const clicked = await api.contextMenu.show( + [ + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); if (clicked === "open-in-browser") { - void openExternalLinkInPreview(href); + operation = "open-link-in-preview"; + const result = await openExternalLinkInPreview(href); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure({ operation, target: href }, result.cause); + } return; } - if (clicked === "open-external") return api.shell.openExternal(href); - }) - .catch(() => undefined); + if (clicked === "open-external") { + operation = "open-link-external"; + await api.shell.openExternal(href); + } + } catch (cause) { + reportMarkdownActionFailure({ operation, target: href }, cause); + } + })(); }} > {faviconHost ? ( From 9a78c6f2bab95bdb899dffcefd50fa52f7b61c07 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:46 -0700 Subject: [PATCH 193/257] [codex] Structure web local storage failures (#3350) Co-authored-by: codex --- apps/web/src/clientPersistenceStorage.test.ts | 18 +++ apps/web/src/clientPersistenceStorage.ts | 3 +- .../src/components/files/FilePreviewPanel.tsx | 13 +- apps/web/src/hooks/useLocalStorage.test.ts | 121 ++++++++++++++++++ apps/web/src/hooks/useLocalStorage.ts | 99 ++++++++++---- apps/web/src/hooks/useResizableWidth.ts | 7 +- apps/web/src/providerUpdateDismissal.ts | 7 +- apps/web/src/versionSkew.ts | 7 +- 8 files changed, 237 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/hooks/useLocalStorage.test.ts diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 6c449eea2b1..ec335892bea 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -51,4 +51,22 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); + + it("reports structured decode failures while preserving the fallback", async () => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(readBrowserClientSettings()).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + "Could not read persisted client settings.", + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + cause: expect.anything(), + }), + ); + }); }); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index b6a9f1f8e03..5c0ba7c6ecc 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -15,7 +15,8 @@ export function readBrowserClientSettings(): ClientSettings | null { try { return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch { + } catch (error) { + console.error("Could not read persisted client settings.", error); return null; } } diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ba0be2da2da..89176cd4525 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -12,12 +12,14 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useTheme } from "~/hooks/useTheme"; +import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -589,8 +591,9 @@ function RenderedMarkdownSurface({ function initialExplorerOpen(): boolean { try { - return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false"; - } catch { + return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true; + } catch (error) { + console.error(error); return true; } } @@ -650,8 +653,10 @@ export default function FilePreviewPanel({ setExplorerOpen((current) => { const next = !current; try { - window.localStorage.setItem(FILE_EXPLORER_STORAGE_KEY, String(next)); - } catch {} + setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, next, Schema.Boolean); + } catch (error) { + console.error(error); + } return next; }); }; diff --git a/apps/web/src/hooks/useLocalStorage.test.ts b/apps/web/src/hooks/useLocalStorage.test.ts new file mode 100644 index 00000000000..27627a36e4b --- /dev/null +++ b/apps/web/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,121 @@ +import * as Schema from "effect/Schema"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +async function loadWithStorage(storage: Storage) { + vi.stubGlobal("window", { localStorage: storage }); + vi.stubGlobal("localStorage", storage); + return import("./useLocalStorage"); +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +describe("local storage errors", () => { + it("preserves read failure context", async () => { + const cause = new Error("storage unavailable"); + const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( + createStorage({ + getItem: () => { + throw cause; + }, + }), + ); + + try { + getLocalStorageItem("read-key", Schema.String); + expect.unreachable("expected the read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "read-key", + cause, + }); + } + }); + + it("preserves decode failure context", async () => { + const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( + createStorage({ getItem: () => "not-json" }), + ); + + try { + getLocalStorageItem("decode-key", Schema.String); + expect.unreachable("expected decoding to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "decode", + storageKey: "decode-key", + cause: expect.anything(), + }); + } + }); + + it("preserves write failure context", async () => { + const cause = new Error("storage quota exceeded"); + const { LocalStorageOperationError, setLocalStorageItem } = await loadWithStorage( + createStorage({ + setItem: () => { + throw cause; + }, + }), + ); + + try { + setLocalStorageItem("write-key", "value", Schema.String); + expect.unreachable("expected the write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "write-key", + cause, + }); + } + }); + + it("preserves removal failure context", async () => { + const cause = new Error("storage unavailable"); + const { LocalStorageOperationError, removeLocalStorageItem } = await loadWithStorage( + createStorage({ + removeItem: () => { + throw cause; + }, + }), + ); + + try { + removeLocalStorageItem("remove-key"); + expect.unreachable("expected the removal to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "remove", + storageKey: "remove-key", + cause, + }); + } + }); +}); diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 50e81dbc0b8..3099e73ff43 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -2,6 +2,19 @@ import * as Schema from "effect/Schema"; import * as Record from "effect/Record"; import { useCallback, useMemo, useSyncExternalStore } from "react"; +export class LocalStorageOperationError extends Schema.TaggedErrorClass()( + "LocalStorageOperationError", + { + operation: Schema.Literals(["read", "decode", "encode", "update", "write", "remove", "notify"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} local storage item ${this.storageKey}.`; + } +} + const isomorphicLocalStorage: Storage = typeof window !== "undefined" ? window.localStorage @@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage = }; })(); -const decode = (schema: Schema.Codec, value: string) => { - const decodeJson = Schema.decodeSync(Schema.fromJsonString(schema)); - return decodeJson(value); +const read = (key: string) => { + try { + return isomorphicLocalStorage.getItem(key); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); + } +}; + +const decode = (key: string, schema: Schema.Codec, value: string) => { + try { + return Schema.decodeSync(Schema.fromJsonString(schema))(value); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause }); + } }; -const encode = (schema: Schema.Codec, value: T) => { - const encodeJson = Schema.encodeSync(Schema.fromJsonString(schema)); - return encodeJson(value); +const encode = (key: string, schema: Schema.Codec, value: T) => { + try { + return Schema.encodeSync(Schema.fromJsonString(schema))(value); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause }); + } }; export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { - const item = isomorphicLocalStorage.getItem(key); - return item ? decode(schema, item) : null; + const item = read(key); + return item ? decode(key, schema, item) : null; }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { - const valueToSet = encode(schema, value); - isomorphicLocalStorage.setItem(key, valueToSet); + const valueToSet = encode(key, schema, value); + try { + isomorphicLocalStorage.setItem(key, valueToSet); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); + } }; export const removeLocalStorageItem = (key: string) => { - isomorphicLocalStorage.removeItem(key); + try { + isomorphicLocalStorage.removeItem(key); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); + } }; const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; @@ -51,11 +86,15 @@ interface LocalStorageChangeDetail { function dispatchLocalStorageChange(key: string) { if (typeof window === "undefined") return; - window.dispatchEvent( - new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { - detail: { key }, - }), - ); + try { + window.dispatchEvent( + new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { + detail: { key }, + }), + ); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause }); + } } export function useLocalStorage( @@ -65,9 +104,9 @@ export function useLocalStorage( ): [T, (value: T | ((val: T) => T)) => void] { const getSnapshot = useCallback(() => { try { - return isomorphicLocalStorage.getItem(key); + return read(key); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not read stored value.", error); return null; } }, [key]); @@ -101,19 +140,31 @@ export function useLocalStorage( return initialValue; } try { - return decode(schema, serializedValue); + return decode(key, schema, serializedValue); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not decode stored value.", error); return initialValue; } - }, [initialValue, schema, serializedValue]); + }, [initialValue, key, schema, serializedValue]); const setValue = useCallback( (value: T | ((val: T) => T)) => { try { const currentValue = getLocalStorageItem(key, schema) ?? initialValue; - const valueToStore = - typeof value === "function" ? (value as (val: T) => T)(currentValue) : value; + let valueToStore: T; + if (typeof value === "function") { + try { + valueToStore = (value as (val: T) => T)(currentValue); + } catch (cause) { + throw new LocalStorageOperationError({ + operation: "update", + storageKey: key, + cause, + }); + } + } else { + valueToStore = value; + } if (valueToStore === null) { removeLocalStorageItem(key); } else { @@ -121,7 +172,7 @@ export function useLocalStorage( } dispatchLocalStorageChange(key); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not update stored value.", error); } }, [initialValue, key, schema], diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index d3c7207c185..08c067471f7 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -55,7 +55,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { try { const stored = getLocalStorageItem(storageKey, WidthSchema); return clamp(stored ?? defaultWidth); - } catch { + } catch (error) { + console.error("Could not read persisted panel width.", error); return defaultWidth; } }); @@ -141,8 +142,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { // Commit once at drag-end to avoid 60Hz localStorage writes. try { setLocalStorageItem(storageKey, finalWidth, WidthSchema); - } catch { - // localStorage may be full / disabled; the in-memory state still wins. + } catch (error) { + console.error("Could not persist panel width.", error); } setWidth(finalWidth); }, diff --git a/apps/web/src/providerUpdateDismissal.ts b/apps/web/src/providerUpdateDismissal.ts index 7cce819ccf9..28789152b34 100644 --- a/apps/web/src/providerUpdateDismissal.ts +++ b/apps/web/src/providerUpdateDismissal.ts @@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals { keys: [], } ); - } catch { + } catch (error) { + console.error("Could not read provider-update dismissals.", error); return { keys: [] }; } } @@ -33,8 +34,8 @@ function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void document, ProviderUpdateDismissalsSchema, ); - } catch { - // Dismissal state is best-effort UI state; a storage failure should not block the toast. + } catch (error) { + console.error("Could not persist provider-update dismissals.", error); } } diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index cb0116c8550..88691cfc25e 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -64,7 +64,8 @@ function readVersionMismatchDismissals(): VersionMismatchDismissals { VersionMismatchDismissalsSchema, ) ?? { keys: [] } ); - } catch { + } catch (error) { + console.error("Could not read version-mismatch dismissals.", error); return { keys: [] }; } } @@ -76,8 +77,8 @@ function writeVersionMismatchDismissals(document: VersionMismatchDismissals): vo document, VersionMismatchDismissalsSchema, ); - } catch { - // Dismissal state is best-effort UI state; a storage failure should not block the banner. + } catch (error) { + console.error("Could not persist version-mismatch dismissals.", error); } } From 8ce627ba91a6c4e5b336bc50c2fa06ebf4f4ade6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:51 -0700 Subject: [PATCH 194/257] [codex] Structure mobile thread outbox failures (#3341) Co-authored-by: codex --- .../mobile/src/state/thread-outbox-manager.ts | 87 ++++++++++++++-- .../mobile/src/state/thread-outbox-storage.ts | 98 +++++++++++++++---- apps/mobile/src/state/thread-outbox.test.ts | 53 +++++++++- 3 files changed, 208 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index 477cb1273a3..7762e6cdf78 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -1,4 +1,5 @@ -import type { EnvironmentId, MessageId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; import { @@ -8,6 +9,27 @@ import { } from "./thread-outbox-model"; import type { ThreadOutboxStorage } from "./thread-outbox-storage"; +export class ThreadOutboxManagerError extends Schema.TaggedErrorClass()( + "ThreadOutboxManagerError", + { + operation: Schema.Literals([ + "load", + "enqueue", + "remove", + "clear-environment-load", + "clear-environment-remove", + ]), + environmentId: Schema.NullOr(EnvironmentId), + threadId: Schema.NullOr(ThreadId), + messageId: Schema.NullOr(MessageId), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Thread outbox operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}.`; + } +} + export interface ThreadOutboxManagerOptions { readonly registry: AtomRegistry.AtomRegistry; readonly storage: ThreadOutboxStorage; @@ -49,22 +71,51 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { loadPromise = serialize(async () => { const persistedMessages = await options.storage.load(); setMessages([...persistedMessages, ...currentMessages()]); - }).catch((error) => { + }).catch((cause) => { loadPromise = null; - warn("[thread-outbox] failed to load persisted messages", error); + warn( + "[thread-outbox] failed to load persisted messages", + new ThreadOutboxManagerError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + cause, + }), + ); }); return loadPromise; }; const enqueue = (message: QueuedThreadMessage): Promise => serialize(async () => { - await options.storage.write(message); + try { + await options.storage.write(message); + } catch (cause) { + throw new ThreadOutboxManagerError({ + operation: "enqueue", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }); + } setMessages([...currentMessages(), message]); }); const remove = (message: QueuedThreadMessage): Promise => serialize(async () => { - await options.storage.remove(message); + try { + await options.storage.remove(message); + } catch (cause) { + throw new ThreadOutboxManagerError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }); + } setMessages( currentMessages().filter((candidate) => candidate.messageId !== message.messageId), ); @@ -72,8 +123,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { 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); + const persisted = await options.storage.load().catch((cause) => { + warn( + "[thread-outbox] failed to load messages while clearing environment", + new ThreadOutboxManagerError({ + operation: "clear-environment-load", + environmentId, + threadId: null, + messageId: null, + cause, + }), + ); return []; }); const allMessages = flattenQueuedThreadMessages( @@ -88,8 +148,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { try { await options.storage.remove(message); removedMessageIds.add(message.messageId); - } catch (error) { - warn("[thread-outbox] failed to clear persisted message", error); + } catch (cause) { + warn( + "[thread-outbox] failed to clear persisted message", + new ThreadOutboxManagerError({ + operation: "clear-environment-remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }), + ); } }), ); diff --git a/apps/mobile/src/state/thread-outbox-storage.ts b/apps/mobile/src/state/thread-outbox-storage.ts index e294aee4549..2003c220bad 100644 --- a/apps/mobile/src/state/thread-outbox-storage.ts +++ b/apps/mobile/src/state/thread-outbox-storage.ts @@ -1,4 +1,5 @@ -import type { MessageId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { decodeQueuedThreadMessage, @@ -8,6 +9,22 @@ import { const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; +export class ThreadOutboxStorageError extends Schema.TaggedErrorClass()( + "ThreadOutboxStorageError", + { + operation: Schema.Literals(["load", "read-message", "write", "remove"]), + environmentId: Schema.NullOr(EnvironmentId), + threadId: Schema.NullOr(ThreadId), + messageId: Schema.NullOr(MessageId), + fileName: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Thread outbox storage operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}, file ${this.fileName ?? "unknown"}.`; + } +} + export interface ThreadOutboxStorage { readonly load: () => Promise>; readonly write: (message: QueuedThreadMessage) => Promise; @@ -32,33 +49,78 @@ async function getMessageFile(messageId: MessageId) { export const expoThreadOutboxStorage: ThreadOutboxStorage = { load: async () => { - const { File } = await import("expo-file-system"); - const directory = await getOutboxDirectory(); const messages: QueuedThreadMessage[] = []; + try { + const { File } = await import("expo-file-system"); + const directory = await getOutboxDirectory(); - for (const entry of directory.list()) { - if (!(entry instanceof File) || !entry.name.endsWith(".json")) { - continue; - } - try { - messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); - } catch (error) { - console.warn("[thread-outbox] ignored invalid persisted message", entry.name, error); + for (const entry of directory.list()) { + if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + continue; + } + try { + messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); + } catch (cause) { + console.warn( + "[thread-outbox] ignored invalid persisted message", + new ThreadOutboxStorageError({ + operation: "read-message", + environmentId: null, + threadId: null, + messageId: null, + fileName: entry.name, + cause, + }), + ); + } } + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + fileName: null, + cause, + }); } return messages; }, write: async (message) => { - const file = await getMessageFile(message.messageId); - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); + const fileName = messageFileName(message.messageId); + try { + const file = await getMessageFile(message.messageId); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encodeQueuedThreadMessage(message))); + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "write", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + fileName, + cause, + }); } - file.write(JSON.stringify(encodeQueuedThreadMessage(message))); }, remove: async (message) => { - const file = await getMessageFile(message.messageId); - if (file.exists) { - file.delete(); + const fileName = messageFileName(message.messageId); + try { + const file = await getMessageFile(message.messageId); + if (file.exists) { + file.delete(); + } + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + fileName, + cause, + }); } }, }; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d2634fb966f..d6b91c1c4f6 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -10,7 +10,7 @@ import { threadOutboxRetryDelayMs, type QueuedThreadMessage, } from "./thread-outbox-model"; -import { createThreadOutboxManager } from "./thread-outbox-manager"; +import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; import type { ThreadOutboxStorage } from "./thread-outbox-storage"; function queuedMessage(input: { @@ -149,9 +149,48 @@ describe("thread outbox", () => { registry.dispose(); }); + it("reports structured load failures and permits a retry", async () => { + const registry = AtomRegistry.make(); + const loadCause = new Error("storage unavailable"); + const warnings: Array<{ message: string; error: unknown }> = []; + let loadCalls = 0; + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => { + loadCalls += 1; + if (loadCalls === 1) throw loadCause; + return []; + }, + write: async () => undefined, + remove: async () => undefined, + }, + warn: (message, error) => warnings.push({ message, error }), + }); + + await manager.load(); + expect(warnings).toEqual([ + { + message: "[thread-outbox] failed to load persisted messages", + error: new ThreadOutboxManagerError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + cause: loadCause, + }), + }, + ]); + + await manager.load(); + expect(loadCalls).toBe(2); + registry.dispose(); + }); + it("keeps atom state aligned with durable writes and removals", async () => { const registry = AtomRegistry.make(); const stored = new Map(); + const removalCause = new Error("remove failed"); let failRemoval = true; const storage: ThreadOutboxStorage = { load: async () => [...stored.values()], @@ -160,7 +199,7 @@ describe("thread outbox", () => { }, remove: async (message) => { if (failRemoval) { - throw new Error("remove failed"); + throw removalCause; } stored.delete(message.messageId); }, @@ -176,7 +215,15 @@ describe("thread outbox", () => { "environment-1:thread-1": [message], }); - await expect(manager.remove(message)).rejects.toThrow("remove failed"); + await expect(manager.remove(message)).rejects.toEqual( + new ThreadOutboxManagerError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause: removalCause, + }), + ); expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ "environment-1:thread-1": [message], }); From 834e5db45843d8b5da4fbfe1999fbaa4c58e117b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:55 -0700 Subject: [PATCH 195/257] [codex] structure project CLI failures (#3339) Co-authored-by: codex --- apps/server/src/cli/project.test.ts | 32 +++++++ apps/server/src/cli/project.ts | 129 ++++++++++++++++++---------- 2 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/cli/project.test.ts diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts new file mode 100644 index 00000000000..4d7e47ce541 --- /dev/null +++ b/apps/server/src/cli/project.test.ts @@ -0,0 +1,32 @@ +import { assert, it } from "@effect/vitest"; + +import { EnvironmentInternalError } from "@t3tools/contracts"; + +import { ProjectCommandError } from "./project.ts"; + +it("maps declared server failures into structural project command errors", () => { + const cause = new EnvironmentInternalError({ + code: "internal_error", + reason: "orchestration_snapshot_failed", + traceId: "trace-123", + }); + + const error = ProjectCommandError.fromLiveServerRequest(cause); + + assert.strictEqual(error.operation, "callLiveServer"); + assert.strictEqual(error.code, "internal_error"); + assert.strictEqual(error.traceId, "trace-123"); + assert.strictEqual(error.message, "Server request failed (internal_error, trace trace-123)."); + assert.strictEqual(error.cause, cause); +}); + +it("preserves unexpected server failures without deriving the message from them", () => { + const cause = new Error("credential abc123 was rejected"); + + const error = ProjectCommandError.fromLiveServerRequest(cause); + + assert.strictEqual(error.operation, "callLiveServer"); + assert.strictEqual(error.detail, "Failed to call the running server."); + assert.strictEqual(error.message, "Failed to call the running server."); + assert.strictEqual(error.cause, cause); +}); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index d52d5b214d8..16f1f0e14d7 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -9,11 +9,9 @@ import { } from "@t3tools/contracts"; import * as Console from "effect/Console"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -52,16 +50,64 @@ type ProjectCliDispatchCommand = Extract< { type: "project.create" | "project.meta.update" | "project.delete" } >; -class ProjectCommandError extends Data.TaggedError("ProjectCommandError")<{ - readonly message: string; -}> {} +const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); +const ProjectCommandOperation = Schema.Literals([ + "generateProjectCommandId", + "callLiveServer", + "validateProjectTitle", + "resolveProjectTarget", + "addProject", +]); + +export class ProjectCommandError extends Schema.TaggedErrorClass()( + "ProjectCommandError", + { + operation: ProjectCommandOperation, + detail: Schema.String, + code: Schema.optional(Schema.String), + traceId: Schema.optional(Schema.String), + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + static fromLiveServerRequest(cause: unknown): ProjectCommandError { + if (isEnvironmentHttpCommonError(cause)) { + return new ProjectCommandError({ + operation: "callLiveServer", + detail: `Server request failed (${cause.code}, trace ${cause.traceId}).`, + code: cause.code, + traceId: cause.traceId, + cause, + }); + } + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + return new ProjectCommandError({ + operation: "callLiveServer", + detail: `Server request failed with undeclared status ${cause.response.status}.`, + status: cause.response.status, + cause, + }); + } + return new ProjectCommandError({ + operation: "callLiveServer", + detail: "Failed to call the running server.", + cause, + }); + } + + override get message(): string { + return this.detail; + } +} const projectCommandUuid = Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), Effect.mapError( - () => + (cause) => new ProjectCommandError({ - message: "Failed to generate a project command identifier.", + operation: "generateProjectCommandId", + detail: "Failed to generate a project command identifier.", + cause, }), ), ); @@ -75,7 +121,6 @@ const ProjectCliRuntimeLive = Layer.mergeAll( ); const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); -const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); const withProjectCliSessionToken = ( environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], run: (token: string) => Effect.Effect, @@ -92,28 +137,6 @@ const withProjectCliSessionToken = ( const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); -const failLiveServerRequest = (cause: unknown) => { - if (isEnvironmentHttpCommonError(cause)) { - return Effect.fail( - new ProjectCommandError({ - message: `Server request failed (${cause.code}, trace ${cause.traceId}).`, - }), - ); - } - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - return Effect.fail( - new ProjectCommandError({ - message: `Server request failed with undeclared status ${cause.response.status}.`, - }), - ); - } - return Effect.fail( - new ProjectCommandError({ - message: `Failed to call running server: ${String(cause)}.`, - }), - ); -}; - const makeLiveServerClient = (origin: string) => HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin, @@ -135,7 +158,10 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( if (trimmed.length > 0) { return trimmed; } - return yield* new ProjectCommandError({ message: "Project title cannot be empty." }); + return yield* new ProjectCommandError({ + operation: "validateProjectTitle", + detail: "Project title cannot be empty.", + }); } const path = yield* Path.Path; @@ -149,7 +175,10 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( }) { const trimmedIdentifier = input.identifier.trim(); if (trimmedIdentifier.length === 0) { - return yield* new ProjectCommandError({ message: "Project identifier cannot be empty." }); + return yield* new ProjectCommandError({ + operation: "resolveProjectTarget", + detail: "Project identifier cannot be empty.", + }); } const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); @@ -162,12 +191,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( } satisfies ProjectMutationTarget; } - const normalizedWorkspaceRootResult = yield* Effect.exit( + const normalizedWorkspaceRootResult = yield* Effect.result( normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), ); - const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) - ? normalizedWorkspaceRootResult.value - : null; + const normalizedWorkspaceRoot = + normalizedWorkspaceRootResult._tag === "Success" ? normalizedWorkspaceRootResult.success : null; const exactWorkspaceMatch = normalizedWorkspaceRoot === null @@ -177,7 +205,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( const resolved = exactWorkspaceMatch; if (!resolved) { return yield* new ProjectCommandError({ - message: `No active project found for '${trimmedIdentifier}'.`, + operation: "resolveProjectTarget", + detail: `No active project found for '${trimmedIdentifier}'.`, + ...(normalizedWorkspaceRootResult._tag === "Failure" + ? { cause: normalizedWorkspaceRootResult.failure } + : {}), }); } @@ -194,7 +226,10 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => return yield* client.orchestration.snapshot({ headers: { authorization: `Bearer ${bearerToken}` }, }); - }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); + }).pipe( + withProjectCliLiveServerTimeout, + Effect.mapError(ProjectCommandError.fromLiveServerRequest), + ); const dispatchLiveOrchestrationCommand = ( origin: string, @@ -207,7 +242,10 @@ const dispatchLiveOrchestrationCommand = ( headers: { authorization: `Bearer ${bearerToken}` }, payload: command, } as Parameters[0]); - }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); + }).pipe( + withProjectCliLiveServerTimeout, + Effect.mapError(ProjectCommandError.fromLiveServerRequest), + ); const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; @@ -232,11 +270,15 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu ), ); - const attempted = yield* Effect.exit(attempt); - if (Exit.isSuccess(attempted)) { - return Option.some(attempted.value); + const attempted = yield* Effect.result(attempt); + if (attempted._tag === "Success") { + return Option.some(attempted.success); } + yield* Effect.logDebug("Failed to connect to the persisted project CLI server.", { + origin: runtimeState.value.origin, + cause: attempted.failure, + }); yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); return Option.none<{ readonly origin: string }>(); }, @@ -335,7 +377,8 @@ const projectAddCommand = Command.make("add", { ); if (existingProject) { return yield* new ProjectCommandError({ - message: `An active project already exists for '${workspaceRoot}'.`, + operation: "addProject", + detail: `An active project already exists for '${workspaceRoot}'.`, }); } From 42ff43915a2ed5c0f190019c0c1a6a1cb68658c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:15:00 -0700 Subject: [PATCH 196/257] [codex] Preserve mobile review highlighter failures (#3337) Co-authored-by: codex --- .../diffs/nativeReviewDiffHighlighter.ts | 81 ++++++++++++++++--- .../review/reviewHighlighterState.test.ts | 27 +++++-- .../features/review/reviewHighlighterState.ts | 55 +++++++++---- .../features/review/shikiReviewHighlighter.ts | 61 ++++++++++---- .../review/useNativeReviewDiffHighlighting.ts | 6 +- 5 files changed, 182 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 72abcc8c956..6c8c957f541 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -8,6 +8,7 @@ import jsxLanguage from "@shikijs/langs/jsx"; import tsxLanguage from "@shikijs/langs/tsx"; import typescriptLanguage from "@shikijs/langs/typescript"; import yamlLanguage from "@shikijs/langs/yaml"; +import * as Schema from "effect/Schema"; import type { NativeReviewDiffFile, NativeReviewDiffLanguage } from "./nativeReviewDiffTypes"; import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface"; @@ -15,6 +16,32 @@ import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewD export type NativeReviewDiffHighlightScheme = "light" | "dark"; export type NativeReviewDiffHighlightEngine = "native" | "javascript"; +export class NativeReviewDiffHighlighterUnavailableError extends Schema.TaggedErrorClass()( + "NativeReviewDiffHighlighterUnavailableError", + {}, +) { + override get message(): string { + return "The native review diff highlighter is unavailable in this build."; + } +} + +export const isNativeReviewDiffHighlighterUnavailableError = Schema.is( + NativeReviewDiffHighlighterUnavailableError, +); + +export class NativeReviewDiffHighlighterInitializationError extends Schema.TaggedErrorClass()( + "NativeReviewDiffHighlighterInitializationError", + { + requestedEngine: Schema.Literals(["native", "javascript"]), + attemptedEngine: Schema.Literals(["native", "javascript"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the ${this.attemptedEngine} review diff highlighter requested as ${this.requestedEngine}.`; + } +} + export interface NativeReviewDiffHighlighterHandle { readonly engine: NativeReviewDiffHighlightEngine; readonly tokenize: ( @@ -197,7 +224,7 @@ function normalizeTokens( async function createNativeReviewDiffHighlighter(): Promise { const nativeEngineModule = await import("react-native-shiki-engine"); if (!nativeEngineModule.isNativeEngineAvailable()) { - throw new Error("Native Shiki engine is not available in this build."); + throw new NativeReviewDiffHighlighterUnavailableError(); } const highlighter = await createHighlighterCore({ @@ -229,18 +256,52 @@ export async function getNativeReviewDiffHighlighter( engine: NativeReviewDiffHighlightEngine = "native", ): Promise { if (engine === "javascript") { - javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); - return javascriptHighlighterPromise; + try { + javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); + return await javascriptHighlighterPromise; + } catch (cause) { + javascriptHighlighterPromise = null; + throw new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "javascript", + cause, + }); + } } - nativeHighlighterPromise ??= createNativeReviewDiffHighlighter().catch((error: unknown) => { - console.warn("[debug-native-diff] native highlighter unavailable", { - error: error instanceof Error ? error.message : String(error), + nativeHighlighterPromise ??= createNativeReviewDiffHighlighter() + .catch(async (cause: unknown) => { + const nativeError = isNativeReviewDiffHighlighterUnavailableError(cause) + ? cause + : new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "native", + cause, + }); + console.warn("[debug-native-diff] native highlighter unavailable", { + error: nativeError, + }); + try { + javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); + return await javascriptHighlighterPromise; + } catch (fallbackCause) { + javascriptHighlighterPromise = null; + throw new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "javascript", + cause: new AggregateError( + [nativeError, fallbackCause], + "Native and JavaScript review diff highlighter initialization failed.", + { cause: nativeError }, + ), + }); + } + }) + .catch((error) => { + nativeHighlighterPromise = null; + throw error; }); - javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); - return javascriptHighlighterPromise; - }); - return nativeHighlighterPromise; + return await nativeHighlighterPromise; } function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow { diff --git a/apps/mobile/src/features/review/reviewHighlighterState.test.ts b/apps/mobile/src/features/review/reviewHighlighterState.test.ts index 43ec2e04182..9cc43d07f2a 100644 --- a/apps/mobile/src/features/review/reviewHighlighterState.test.ts +++ b/apps/mobile/src/features/review/reviewHighlighterState.test.ts @@ -53,11 +53,12 @@ it("initializes review highlighter state once", async () => { }); it("stores initialization failures in atom state", async () => { + const cause = new Error("load failed"); const manager = createReviewHighlighterManager({ getRegistry: () => registry, loader: { prepare: async () => { - throw new Error("load failed"); + throw cause; }, prepareLanguages: async () => undefined, getEngine: async () => "javascript", @@ -67,9 +68,23 @@ it("stores initialization failures in atom state", async () => { void manager.initialize(); await flushAsyncWork(); - assert.deepStrictEqual(manager.getSnapshot(), { - engine: null, - error: "load failed", - status: "error", - }); + const snapshot = manager.getSnapshot(); + assert.strictEqual(snapshot.engine, null); + assert.strictEqual(snapshot.status, "error"); + assert.strictEqual(snapshot.error?._tag, "ReviewHighlighterManagerError"); + assert.strictEqual(snapshot.error?.operation, "prepare"); + assert.deepStrictEqual(snapshot.error?.languages, [ + "typescript", + "tsx", + "javascript", + "jsx", + "json", + "yaml", + "bash", + ]); + assert.strictEqual(snapshot.error?.cause, cause); + assert.strictEqual( + snapshot.error?.message, + "Review highlighter operation prepare failed for languages typescript, tsx, javascript, jsx, json, yaml, bash.", + ); }); diff --git a/apps/mobile/src/features/review/reviewHighlighterState.ts b/apps/mobile/src/features/review/reviewHighlighterState.ts index 2622ecbe050..51b20bb07ff 100644 --- a/apps/mobile/src/features/review/reviewHighlighterState.ts +++ b/apps/mobile/src/features/review/reviewHighlighterState.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; import { useEffect } from "react"; @@ -12,9 +13,22 @@ import { export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error"; +export class ReviewHighlighterManagerError extends Schema.TaggedErrorClass()( + "ReviewHighlighterManagerError", + { + operation: Schema.Literals(["prepare", "prepare-languages", "resolve-engine"]), + languages: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Review highlighter operation ${this.operation} failed for languages ${this.languages.join(", ")}.`; + } +} + export interface ReviewHighlighterState { readonly engine: ReviewHighlighterEngine | null; - readonly error: string | null; + readonly error: ReviewHighlighterManagerError | null; readonly status: ReviewHighlighterStatus; } @@ -101,24 +115,35 @@ export function createReviewHighlighterManager(config: { inFlight = (async () => { const startedAt = performance.now(); + const languages = config.languages ?? REVIEW_INITIAL_LANGUAGES; + let operation: ReviewHighlighterManagerError["operation"] = "prepare"; + let engine: ReviewHighlighterEngine; try { await config.loader.prepare(); - await config.loader.prepareLanguages(config.languages ?? REVIEW_INITIAL_LANGUAGES); - const engine = await config.loader.getEngine(); - const durationMs = Math.round(performance.now() - startedAt); - logReviewHighlighterProviderDiagnostic("initialized", { - durationMs, - engine, + operation = "prepare-languages"; + await config.loader.prepareLanguages(languages); + operation = "resolve-engine"; + engine = await config.loader.getEngine(); + } catch (cause) { + const error = new ReviewHighlighterManagerError({ + operation, + languages, + cause, }); - setState({ engine, error: null, status: "ready" }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logReviewHighlighterProviderDiagnostic("initialization failed", { error: message }); - setState({ engine: null, error: message, status: "error" }); - } finally { - inFlight = null; + logReviewHighlighterProviderDiagnostic("initialization failed", { error }); + setState({ engine: null, error, status: "error" }); + return; } - })(); + + const durationMs = Math.round(performance.now() - startedAt); + logReviewHighlighterProviderDiagnostic("initialized", { + durationMs, + engine, + }); + setState({ engine, error: null, status: "ready" }); + })().finally(() => { + inFlight = null; + }); return inFlight; } diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 7030ac77e5f..008a0761949 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -10,6 +10,7 @@ import yamlLanguage from "@shikijs/langs/yaml"; import githubDarkDefault from "@shikijs/themes/github-dark-default"; import githubLightDefault from "@shikijs/themes/github-light-default"; import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; +import * as Schema from "effect/Schema"; import { resolveReviewHighlighterEngine, @@ -22,6 +23,19 @@ import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordD export type ReviewDiffTheme = "light" | "dark"; export type { ReviewHighlighterEngine }; +export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErrorClass()( + "ReviewHighlighterEngineInitializationError", + { + preferredEngine: Schema.Literals(["native", "javascript"]), + attemptedEngine: Schema.Literals(["native", "javascript"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the ${this.attemptedEngine} review highlighter with ${this.preferredEngine} preferred.`; + } +} + export interface ReviewHighlightedToken { content: string; readonly color: string | null; @@ -227,16 +241,6 @@ function logReviewHighlighterDiagnosticError(message: string, error: unknown): v if (!isReviewHighlighterDebugLoggingEnabled()) { return; } - - if (error instanceof Error) { - console.error(`[review-highlighter] ${message}`, { - name: error.name, - message: error.message, - stack: error.stack, - }); - return; - } - console.error(`[review-highlighter] ${message}`, error); } @@ -258,6 +262,7 @@ async function getHighlighter(): Promise { if (!highlighterPromise) { const configuredHighlighterPromise = (async () => { let nativeEngineAvailable = false; + let nativeInitializationError: ReviewHighlighterEngineInitializationError | undefined; logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, @@ -289,9 +294,14 @@ async function getHighlighter(): Promise { }; } } catch (error) { + nativeInitializationError = new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "native", + cause: error, + }); logReviewHighlighterDiagnosticError( "native engine initialization failed; falling back to javascript", - error, + nativeInitializationError, ); nativeEngineAvailable = false; } @@ -305,11 +315,30 @@ async function getHighlighter(): Promise { REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, nativeEngineAvailable, ); - const highlighter = await createHighlighterCore({ - themes, - langs: REVIEW_INITIAL_LANGUAGE_MODULES, - engine: createJavaScriptRegexEngine(), - }); + let highlighter: HighlighterCore; + try { + highlighter = await createHighlighterCore({ + themes, + langs: REVIEW_INITIAL_LANGUAGE_MODULES, + engine: createJavaScriptRegexEngine(), + }); + } catch (cause) { + const javascriptError = new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "javascript", + cause, + }); + if (!nativeInitializationError) throw javascriptError; + throw new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "javascript", + cause: new AggregateError( + [nativeInitializationError, javascriptError], + "Native and JavaScript review highlighter initialization failed.", + { cause: nativeInitializationError }, + ), + }); + } logReviewHighlighterDiagnostic("using javascript engine", { resolvedEngine: engine, }); diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 98df26641a9..35f06c26366 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -108,7 +108,11 @@ export function useNativeReviewDiffHighlighting(input: { } catch (error) { if (!abortController.signal.aborted) { logReviewDiffDiagnostic("native visible highlight failed", { - error: error instanceof Error ? error.message : String(error), + error, + resetKey, + scheme, + firstRowIndex: requestRange.firstRowIndex, + lastRowIndex: requestRange.lastRowIndex, }); } } From 90f1936fade32015253aadff6d43f2271580f4c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:15:05 -0700 Subject: [PATCH 197/257] [codex] Structure server runtime-state failures (#3319) Co-authored-by: codex --- apps/server/src/serverRuntimeState.test.ts | 167 +++++++++++++++++++++ apps/server/src/serverRuntimeState.ts | 92 +++++++++++- 2 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/serverRuntimeState.test.ts diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts new file mode 100644 index 00000000000..749fd3062e9 --- /dev/null +++ b/apps/server/src/serverRuntimeState.test.ts @@ -0,0 +1,167 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; + +import * as ServerRuntimeState from "./serverRuntimeState.ts"; + +const isServerRuntimeStateError = Schema.is(ServerRuntimeState.ServerRuntimeStateError); + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} + +describe("serverRuntimeState", () => { + it.effect("persists and reads the runtime state", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "runtime", "server.json"); + const state: ServerRuntimeState.PersistedServerRuntimeState = { + version: 1, + pid: 123, + host: "127.0.0.1", + port: 4_971, + origin: "http://127.0.0.1:4971", + startedAt: "2026-06-20T00:00:00.000Z", + }; + + yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state }); + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.deepEqual(Option.getOrThrow(restored), state); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("treats a missing runtime state file as absent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState( + path.join(root, "missing.json"), + ); + + assert.isTrue(Option.isNone(restored)); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves malformed state decode failures", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "server.json"); + yield* fileSystem.writeFileString(statePath, "{not json"); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.isTrue(Option.isNone(restored)); + assert.equal(logs[0]?.message, `Failed to decode server runtime state at ${statePath}.`); + const error = logs[0]?.annotations.cause; + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "decode"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to decode server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "SchemaError" }); + } + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + + it.effect("preserves runtime state read failures", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "server.json"); + yield* fileSystem.makeDirectory(statePath); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.isTrue(Option.isNone(restored)); + assert.equal(logs[0]?.message, `Failed to read server runtime state at ${statePath}.`); + const error = logs[0]?.annotations.cause; + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "read"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to read server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + } + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + + it.effect("preserves runtime state persistence failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const blockedDirectory = path.join(root, "not-a-directory"); + const statePath = path.join(blockedDirectory, "server.json"); + yield* fileSystem.writeFileString(blockedDirectory, "blocked"); + + const error = yield* ServerRuntimeState.persistServerRuntimeState({ + path: statePath, + state: { + version: 1, + pid: 123, + port: 4_971, + origin: "http://127.0.0.1:4971", + startedAt: "2026-06-20T00:00:00.000Z", + }, + }).pipe(Effect.flip); + + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "persist"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to persist server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 289bddcb8bb..329b000369a 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -18,6 +18,19 @@ export const PersistedServerRuntimeState = Schema.Struct({ }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; +export class ServerRuntimeStateError extends Schema.TaggedErrorClass()( + "ServerRuntimeStateError", + { + operation: Schema.Literals(["persist", "read", "decode", "clear"]), + statePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} server runtime state at ${this.statePath}.`; + } +} + const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( Schema.fromJsonString(PersistedServerRuntimeState), ); @@ -51,27 +64,90 @@ export const persistServerRuntimeState = (input: { writeFileStringAtomically({ filePath: input.path, contents: `${JSON.stringify(input.state)}\n`, - }); + }).pipe( + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "persist", + statePath: input.path, + cause, + }), + ), + ); export const clearPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.remove(path, { force: true }).pipe(Effect.ignore({ log: true })); + yield* fs.remove(path, { force: true }).pipe( + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "clear", + statePath: path, + cause, + }), + ), + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + ), + }), + ); }); export const readPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); - if (!exists) { + const raw = yield* fs.readFileString(path).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + new ServerRuntimeStateError({ + operation: "read", + statePath: path, + cause, + }), + ), + onSuccess: (contents) => Effect.succeed(Option.some(contents)), + }), + ); + if (Option.isNone(raw)) { return Option.none(); } - const raw = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => "")); - const trimmed = raw.trim(); + const trimmed = raw.value.trim(); if (trimmed.length === 0) { return Option.none(); } - return yield* decodePersistedServerRuntimeState(trimmed).pipe(Effect.option); - }); + return yield* decodePersistedServerRuntimeState(trimmed).pipe( + Effect.map(Option.some), + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "decode", + statePath: path, + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + Effect.as(Option.none()), + ), + }), + ); From e042c25f568f297402ddfa76477e29aecddaa85f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:24:38 -0700 Subject: [PATCH 198/257] [codex] Structure rotating log sink errors (#3279) Co-authored-by: codex --- packages/shared/src/logging.test.ts | 151 ++++++++++++++++++++++++++++ packages/shared/src/logging.ts | 96 +++++++++++++++--- 2 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/logging.test.ts diff --git a/packages/shared/src/logging.test.ts b/packages/shared/src/logging.test.ts new file mode 100644 index 00000000000..0e1ea2738bc --- /dev/null +++ b/packages/shared/src/logging.test.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + RotatingFileSink, + RotatingFileSinkConfigurationError, + RotatingFileSinkError, +} from "./logging.ts"; + +const tempDirectories: string[] = []; + +const makeTempDirectory = (): string => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-logging-")); + tempDirectories.push(directory); + return directory; +}; + +const captureError = (run: () => unknown): unknown => { + try { + run(); + } catch (cause) { + return cause; + } + throw new Error("Expected operation to throw"); +}; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("RotatingFileSink", () => { + it.each([ + { option: "maxBytes" as const, maxBytes: 0, maxFiles: 1 }, + { option: "maxFiles" as const, maxBytes: 1, maxFiles: 0 }, + ])("reports invalid $option configuration structurally", (input) => { + const thrown = captureError( + () => + new RotatingFileSink({ + filePath: "/unused/log.ndjson", + maxBytes: input.maxBytes, + maxFiles: input.maxFiles, + }), + ); + + expect(thrown).toBeInstanceOf(RotatingFileSinkConfigurationError); + expect(thrown).toMatchObject({ + option: input.option, + received: 0, + minimum: 1, + }); + expect((thrown as Error).message).toBe(`${input.option} must be >= 1 (received 0)`); + }); + + it("preserves directory initialization failures", () => { + const directory = makeTempDirectory(); + const parentFile = NodePath.join(directory, "not-a-directory"); + const filePath = NodePath.join(parentFile, "log.ndjson"); + NodeFS.writeFileSync(parentFile, "occupied"); + + const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 })); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "initialize", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); + + it("only treats a missing log file as an empty current size", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "a".repeat(300)); + + const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 })); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "read", filePath }); + expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "ENAMETOOLONG" }); + }); + + it("starts an absent log file at zero bytes", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + const sink = new RotatingFileSink({ filePath, maxBytes: 100, maxFiles: 1 }); + + sink.write("entry"); + + expect(NodeFS.readFileSync(filePath, "utf8")).toBe("entry"); + }); + + it("preserves write failures", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + NodeFS.mkdirSync(filePath); + const sink = new RotatingFileSink({ + filePath, + maxBytes: Number.MAX_SAFE_INTEGER, + maxFiles: 1, + throwOnError: true, + }); + + const thrown = captureError(() => sink.write("entry")); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "write", filePath }); + expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "EISDIR" }); + }); + + it("preserves rotation failures without an artificial write wrapper", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + NodeFS.writeFileSync(filePath, "a"); + NodeFS.mkdirSync(`${filePath}.1`); + const sink = new RotatingFileSink({ + filePath, + maxBytes: 1, + maxFiles: 1, + throwOnError: true, + }); + + const thrown = captureError(() => sink.write("b")); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "rotate", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); + + it("preserves backup pruning failures", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + const overflowBackup = `${filePath}.2`; + NodeFS.mkdirSync(overflowBackup); + NodeFS.writeFileSync(NodePath.join(overflowBackup, "entry"), "occupied"); + + const thrown = captureError( + () => + new RotatingFileSink({ + filePath, + maxBytes: 1, + maxFiles: 1, + throwOnError: true, + }), + ); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "prune", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); +}); diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts index e19d5cd0efa..4aa9c7843a6 100644 --- a/packages/shared/src/logging.ts +++ b/packages/shared/src/logging.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import * as Schema from "effect/Schema"; export interface RotatingFileSinkOptions { readonly filePath: string; @@ -9,6 +10,37 @@ export interface RotatingFileSinkOptions { readonly throwOnError?: boolean; } +export class RotatingFileSinkConfigurationError extends Schema.TaggedErrorClass()( + "RotatingFileSinkConfigurationError", + { + option: Schema.Literals(["maxBytes", "maxFiles"]), + received: Schema.Number, + minimum: Schema.Number, + }, +) { + override get message(): string { + return `${this.option} must be >= ${this.minimum} (received ${this.received})`; + } +} + +export class RotatingFileSinkError extends Schema.TaggedErrorClass()( + "RotatingFileSinkError", + { + operation: Schema.Literals(["initialize", "read", "write", "rotate", "prune"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} rotating log file ${this.filePath}`; + } +} + +const isRotatingFileSinkError = Schema.is(RotatingFileSinkError); + +const isFileNotFoundError = (cause: unknown): cause is NodeJS.ErrnoException => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + export class RotatingFileSink { private readonly filePath: string; private readonly maxBytes: number; @@ -18,10 +50,18 @@ export class RotatingFileSink { constructor(options: RotatingFileSinkOptions) { if (options.maxBytes < 1) { - throw new Error(`maxBytes must be >= 1 (received ${options.maxBytes})`); + throw new RotatingFileSinkConfigurationError({ + option: "maxBytes", + received: options.maxBytes, + minimum: 1, + }); } if (options.maxFiles < 1) { - throw new Error(`maxFiles must be >= 1 (received ${options.maxFiles})`); + throw new RotatingFileSinkConfigurationError({ + option: "maxFiles", + received: options.maxFiles, + minimum: 1, + }); } this.filePath = options.filePath; @@ -29,7 +69,15 @@ export class RotatingFileSink { this.maxFiles = options.maxFiles; this.throwOnError = options.throwOnError ?? false; - NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true }); + try { + NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true }); + } catch (cause) { + throw new RotatingFileSinkError({ + operation: "initialize", + filePath: this.filePath, + cause, + }); + } this.pruneOverflowBackups(); this.currentSize = this.readCurrentSize(); } @@ -49,11 +97,18 @@ export class RotatingFileSink { if (this.currentSize > this.maxBytes) { this.rotate(); } - } catch { - this.currentSize = this.readCurrentSize(); + } catch (cause) { + if (isRotatingFileSinkError(cause)) { + throw cause; + } if (this.throwOnError) { - throw new Error(`Failed to write log chunk to ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "write", + filePath: this.filePath, + cause, + }); } + this.currentSize = this.readCurrentSize(); } } @@ -77,11 +132,15 @@ export class RotatingFileSink { } this.currentSize = 0; - } catch { - this.currentSize = this.readCurrentSize(); + } catch (cause) { if (this.throwOnError) { - throw new Error(`Failed to rotate log file ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "rotate", + filePath: this.filePath, + cause, + }); } + this.currentSize = this.readCurrentSize(); } } @@ -95,9 +154,13 @@ export class RotatingFileSink { if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue; NodeFS.rmSync(NodePath.join(dir, entry), { force: true }); } - } catch { + } catch (cause) { if (this.throwOnError) { - throw new Error(`Failed to prune log backups for ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "prune", + filePath: this.filePath, + cause, + }); } } } @@ -105,8 +168,15 @@ export class RotatingFileSink { private readCurrentSize(): number { try { return NodeFS.statSync(this.filePath).size; - } catch { - return 0; + } catch (cause) { + if (isFileNotFoundError(cause)) { + return 0; + } + throw new RotatingFileSinkError({ + operation: "read", + filePath: this.filePath, + cause, + }); } } From f2cb14e77486174f3e09092e8023b632488006ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:31:33 -0700 Subject: [PATCH 199/257] [codex] Structure mobile relay token-store failures (#3309) Co-authored-by: codex --- .../cloud/managedRelayTokenStore.test.ts | 36 +++++++- .../features/cloud/managedRelayTokenStore.ts | 85 ++++++++++++------- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts index 616fc1add7c..9642e5f63ae 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts @@ -1,5 +1,7 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import * as SecureStore from "expo-secure-store"; import { vi } from "vite-plus/test"; const secureStore = vi.hoisted(() => new Map()); @@ -16,7 +18,10 @@ vi.mock("expo-secure-store", () => ({ }), })); -import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; +import { + ManagedRelayTokenStoreError, + managedRelayAccessTokenStore, +} from "./managedRelayTokenStore"; it.effect("round-trips and clears persisted managed relay access tokens", () => Effect.gen(function* () { @@ -49,3 +54,32 @@ it.effect("falls back to an empty cache when persisted data is invalid", () => expect(yield* managedRelayAccessTokenStore.load).toEqual([]); }), ); + +it.effect("logs structured storage failures before falling back to an empty cache", () => { + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + const cause = new Error("secure store unavailable"); + vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(cause); + + return Effect.gen(function* () { + expect(yield* managedRelayAccessTokenStore.load).toEqual([]); + + const message = messages.find( + (candidate) => + Array.isArray(candidate) && candidate[0] === "Managed relay token store operation failed.", + ); + expect(message).toBeDefined(); + const context = (message as ReadonlyArray)[1] as { + readonly cause: ManagedRelayTokenStoreError; + }; + expect(context.cause).toBeInstanceOf(ManagedRelayTokenStoreError); + expect(context.cause).toMatchObject({ + operation: "read", + storageKey: "t3code.cloud.relay-access-tokens", + cause, + }); + expect(context.cause.message).not.toContain(cause.message); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); +}); diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts index 460c71c1fa7..0730f277f3c 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -1,5 +1,4 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; @@ -31,36 +30,50 @@ const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect( ); 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 }); +export class ManagedRelayTokenStoreError extends Schema.TaggedErrorClass()( + "ManagedRelayTokenStoreError", + { + operation: Schema.Literals(["read", "decode", "encode", "write", "clear"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Managed relay token store operation "${this.operation}" failed for key "${this.storageKey}".`; + } +} -function logStoreFailure(operation: string) { - return (error: ManagedRelayTokenStoreError) => - Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe( - Effect.annotateLogs({ - errorTag: error._tag, - message: error.message, - }), - ); +function logStoreFailure(error: ManagedRelayTokenStoreError) { + return Effect.logWarning("Managed relay token store operation failed.", { + errorTag: error._tag, + operation: error.operation, + storageKey: error.storageKey, + cause: error, + }); } const loadManagedRelayAccessTokens = Effect.tryPromise({ try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), - catch: storeError("Could not read persisted relay access tokens."), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "read", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), }).pipe( Effect.flatMap((encoded) => encoded === null ? Effect.succeed>([]) : decodeManagedRelayAccessTokenCache(encoded).pipe( Effect.map((cache) => cache.entries), - Effect.mapError(storeError("Persisted relay access tokens are invalid.")), + Effect.mapError( + (cause) => + new ManagedRelayTokenStoreError({ + operation: "decode", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), + ), ), ), ); @@ -72,34 +85,48 @@ const saveManagedRelayAccessTokens = ( version: MANAGED_RELAY_TOKEN_CACHE_VERSION, entries, }).pipe( - Effect.mapError(storeError("Could not encode relay access tokens.")), + Effect.mapError( + (cause) => + new ManagedRelayTokenStoreError({ + operation: "encode", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), + ), Effect.flatMap((encoded) => Effect.tryPromise({ try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded), - catch: storeError("Could not persist relay access tokens."), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "write", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), }), ), ); const clearManagedRelayAccessTokens = Effect.tryPromise({ try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), - catch: storeError("Could not clear persisted relay access tokens."), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "clear", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), }); export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: loadManagedRelayAccessTokens.pipe( - Effect.tapError(logStoreFailure("load")), + Effect.tapError(logStoreFailure), Effect.orElseSucceed(() => []), Effect.withSpan("mobile.managedRelayTokenStore.load"), ), save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) => - saveManagedRelayAccessTokens(entries).pipe( - Effect.tapError(logStoreFailure("save")), - Effect.ignore, - ), + saveManagedRelayAccessTokens(entries).pipe(Effect.tapError(logStoreFailure), Effect.ignore), ), clear: clearManagedRelayAccessTokens.pipe( - Effect.tapError(logStoreFailure("clear")), + Effect.tapError(logStoreFailure), Effect.ignore, Effect.withSpan("mobile.managedRelayTokenStore.clear"), ), From 674590e0098e397c44c1be50adedc875635209a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:47:58 -0700 Subject: [PATCH 200/257] [codex] Fix terminal cwd error test construction (#3440) Co-authored-by: codex --- apps/server/src/project/ProjectSetupScriptRunner.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index e8d771b74df..fdf95df0b99 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -153,9 +153,8 @@ describe("ProjectSetupScriptRunner", () => { 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({ + const terminalError = new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", - reason: "statFailed", cause: rootCause, }); const project = makeProject([ From ad2cb1dd57dbdb028f6544ad780df4616ed41437 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:55:38 -0700 Subject: [PATCH 201/257] [codex] Diagnose desktop client settings read failures (#3432) Co-authored-by: codex --- .../DesktopClientSettings.diagnostics.test.ts | 129 ++++++++++++++++++ .../src/settings/DesktopClientSettings.ts | 19 ++- 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts new file mode 100644 index 00000000000..5034df44cf7 --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -0,0 +1,129 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopClientSettings from "./DesktopClientSettings.ts"; + +interface LogRecord { + readonly message: unknown; + readonly annotations: Readonly>; +} + +const baseDir = "/virtual-home"; + +function makeLayer(fileSystemLayer: Layer.Layer) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopClientSettings.layer.pipe( + Layer.provideMerge(Layer.mergeAll(environmentLayer, NodeServices.layer, fileSystemLayer)), + ); +} + +const readWithLogs = (fileSystemLayer: Layer.Layer) => { + const records: Array = []; + const logger = Logger.make(({ fiber, message }) => { + records.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + return { + result: yield* settings.get, + settingsPath: environment.clientSettingsPath, + records, + }; + }).pipe( + Effect.provide( + Layer.mergeAll( + makeLayer(fileSystemLayer), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); +}; + +describe("DesktopClientSettings diagnostics", () => { + it.effect("treats a missing settings file as expected without warning", () => + Effect.gen(function* () { + const result = yield* readWithLogs(FileSystem.layerNoop({})); + + assert.isTrue(Option.isNone(result.result)); + assert.deepEqual(result.records, []); + }), + ); + + it.effect("logs non-missing filesystem failures with the settings path", () => { + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: `${baseDir}/userdata/client-settings.json`, + }); + + return Effect.gen(function* () { + const result = yield* readWithLogs( + FileSystem.layerNoop({ + readFileString: () => Effect.fail(permissionError), + }), + ); + + assert.isTrue(Option.isNone(result.result)); + assert.equal(result.records.length, 1); + assert.deepEqual(result.records[0]?.message, [ + "Could not read desktop client settings.", + permissionError, + ]); + assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); + }); + }); + + it.effect("logs malformed settings documents with the settings path", () => + Effect.gen(function* () { + const result = yield* readWithLogs( + FileSystem.layerNoop({ + readFileString: () => Effect.succeed("{not-json"), + }), + ); + + assert.isTrue(Option.isNone(result.result)); + assert.equal(result.records.length, 1); + const message = result.records[0]?.message; + if (!Array.isArray(message)) { + return assert.fail("expected structured warning arguments"); + } + assert.equal(message[0], "Could not decode desktop client settings."); + const schemaError = message[1]; + if (schemaError === null || typeof schemaError !== "object") { + return assert.fail("expected the schema error in the warning"); + } + assert.equal("_tag" in schemaError ? schemaError._tag : undefined, "SchemaError"); + assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); + }), + ); +}); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index d08184f4ab7..4ff091e27a2 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -67,14 +67,29 @@ const readClientSettings = ( settingsPath: string, ): Effect.Effect> => fileSystem.readFileString(settingsPath).pipe( - Effect.option, + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.logWarning("Could not read desktop client settings.", cause).pipe( + Effect.annotateLogs({ settingsPath }), + Effect.as(Option.none()), + ), + }), Effect.flatMap( Option.match({ onNone: () => Effect.succeed(Option.none()), onSome: (raw) => decodeClientSettingsJson(raw).pipe( Effect.map((settings) => Option.some(settings)), - Effect.orElseSucceed(() => Option.none()), + Effect.catchTags({ + SchemaError: (cause) => + Effect.logWarning("Could not decode desktop client settings.", cause).pipe( + Effect.annotateLogs({ settingsPath }), + Effect.as(Option.none()), + ), + }), ), }), ), From 60c9ca0e33dcb30c9c8348b37615a10615fc4e5f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:55:44 -0700 Subject: [PATCH 202/257] [codex] Preserve checkpoint repository detection failures (#3360) Co-authored-by: codex --- .../src/checkpointing/CheckpointStore.test.ts | 21 +++++++++++++++++++ .../src/checkpointing/CheckpointStore.ts | 7 +++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 5a60012108b..bf332d20d0d 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -93,6 +93,27 @@ function buildLargeText(lineCount = 5_000): string { } it.layer(TestLayer)("CheckpointStore.layer", (it) => { + describe("isGitRepository", () => { + it.effect("returns false when no Git repository is detected", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + + expect(yield* checkpointStore.isGitRepository(tmp)).toBe(false); + }), + ); + + it.effect("returns true when a Git repository is detected", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + + expect(yield* checkpointStore.isGitRepository(tmp)).toBe(true); + }), + ); + }); + describe("diffCheckpoints", () => { it.effect("returns full oversized checkpoint diffs without truncation", () => Effect.gen(function* () { diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index ed47d5f117f..f13aa4572c1 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -115,10 +115,9 @@ export const make = Effect.gen(function* () { }); const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => - vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe( - Effect.map(() => true), - Effect.orElseSucceed(() => false), - ); + vcsRegistry + .detect({ cwd, requestedKind: "git" }) + .pipe(Effect.map((repository) => repository !== null)); const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( "captureCheckpoint", From 6ff6c13fc8bff69e2967a4f768a076bbb1b88aa5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:55:50 -0700 Subject: [PATCH 203/257] [codex] structure desktop backend process errors (#3254) Co-authored-by: codex --- .../src/backend/DesktopBackendManager.test.ts | 173 +++++++++++++++- .../src/backend/DesktopBackendManager.ts | 188 ++++++++++++------ 2 files changed, 301 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 4a88be8838a..0c083889f29 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -3,12 +3,15 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -28,6 +31,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), ); +const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", @@ -57,7 +61,7 @@ const configWithObservability: DesktopBackendBootstrapValue = { function makeProcess(options?: { readonly stdout?: Stream.Stream; readonly stderr?: Stream.Stream; - readonly exitCode?: Effect.Effect; + readonly exitCode?: Effect.Effect; readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; }): ChildProcessSpawner.ChildProcessHandle { return ChildProcessSpawner.makeHandle({ @@ -145,6 +149,23 @@ function makeManagerLayer(input: { } describe("DesktopBackendManager", () => { + it("preserves the complete restart cause and schedule context", () => { + const cause = Cause.combine( + Cause.fail(new Error("start failed")), + Cause.die(new Error("restart defect")), + ); + const error = new DesktopBackendManager.DesktopBackendRestartError({ + reason: "backend exited with code 1", + delayMs: 500, + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.reason, "backend exited with code 1"); + assert.equal(error.delayMs, 500); + assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay."); + }); + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => Effect.gen(function* () { let spawnedCommand: ChildProcess.Command | undefined; @@ -218,6 +239,156 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("preserves the readiness timeout cause and process context", () => + Effect.gen(function* () { + const requested = yield* Deferred.make(); + const layer = Layer.merge( + TestClock.layer(), + httpClientLayer((request) => + Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), + ), + ); + + yield* Effect.gen(function* () { + const readiness = yield* DesktopBackendManager.waitForHttpReady({ + executablePath: baseConfig.executablePath, + entryPath: baseConfig.entryPath, + cwd: baseConfig.cwd, + httpBaseUrl: baseConfig.httpBaseUrl, + timeout: Duration.millis(50), + }).pipe(Effect.flip, Effect.forkChild); + + const request = yield* Deferred.await(requested); + assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); + + yield* TestClock.adjust(Duration.millis(50)); + const error = yield* Fiber.join(readiness); + + assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); + assert.equal(error.timeoutMs, 50); + assert.isTrue(Cause.isTimeoutError(error.cause)); + assert.equal( + error.message, + "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", + ); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("reports bootstrap encoding failures with stable process context", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + const error = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + bootstrap: { + ...baseConfig.bootstrap, + port: 0, + }, + }).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessBootstrapEncodeError") { + return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.isDefined(error.cause); + assert.equal( + error.message, + "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", + ); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves spawn failures without deriving their message from the cause", () => + Effect.gen(function* () { + const spawnCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: baseConfig.executablePath, + description: "low-level detail that must not become the public message", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(spawnCause)), + ); + const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessSpawnError") { + return assert.fail(`Expected backend spawn error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, spawnCause); + assert.equal( + error.message, + "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", + ); + assert.notInclude(error.message, spawnCause.message); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves exit-status failures without copying their detail into the message", () => + Effect.gen(function* () { + const exitCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "exitCode", + description: "exit-status-secret-sentinel", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Effect.fail(exitCause), + }), + ), + ), + ); + const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessExitStatusError") { + return assert.fail(`Expected backend exit-status error, received ${error._tag}`); + } + assert.equal(error.pid, 123); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, exitCause); + assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); + assert.notInclude(error.message, "exit-status-secret-sentinel"); + assert.isTrue(isBackendProcessError(error)); + }), + ); + it.effect("retries HTTP readiness before reporting the backend ready", () => Effect.gen(function* () { const requestUrls: Array = []; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index bc47cab37d7..8a40c9d2153 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -9,7 +9,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -43,64 +42,107 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; -export interface DesktopBackendStartConfig { +export interface BackendProcessContext { readonly executablePath: string; readonly entryPath: string; readonly cwd: string; + readonly httpBaseUrl: URL; +} + +export interface DesktopBackendStartConfig extends BackendProcessContext { readonly env: Record; readonly bootstrap: DesktopBackendBootstrapValue; - readonly httpBaseUrl: URL; readonly captureOutput: boolean; } interface BackendProcessExit { readonly code: Option.Option; readonly reason: string; - readonly result: Result.Result; } -export class BackendTimeoutError extends Schema.TaggedErrorClass()( - "BackendTimeoutError", +const backendProcessContextSchema = { + executablePath: Schema.String, + entryPath: Schema.String, + cwd: Schema.String, + httpBaseUrl: Schema.URL, +}; + +export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass()( + "BackendReadinessTimeoutError", { - url: Schema.URL, + ...backendProcessContextSchema, + readinessUrl: Schema.URL, + timeoutMs: Schema.Number, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Timed out waiting for backend readiness at ${this.url.href}.`; + return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; } } -class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( +export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( "BackendProcessBootstrapEncodeError", { - detail: Schema.String, + ...backendProcessContextSchema, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to encode desktop backend bootstrap payload: ${this.detail}`; + return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; } } -class BackendProcessSpawnError extends Schema.TaggedErrorClass()( +export class BackendProcessSpawnError extends Schema.TaggedErrorClass()( "BackendProcessSpawnError", { - detail: Schema.String, + ...backendProcessContextSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`; + } +} + +export class BackendProcessExitStatusError extends Schema.TaggedErrorClass()( + "BackendProcessExitStatusError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the exit status of desktop backend process ${this.pid}.`; + } +} + +export class DesktopBackendRestartError extends Schema.TaggedErrorClass()( + "DesktopBackendRestartError", + { + reason: Schema.String, + delayMs: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to spawn desktop backend process: ${this.detail}`; + return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`; } } -type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; +export const BackendProcessError = Schema.Union([ + BackendProcessBootstrapEncodeError, + BackendProcessSpawnError, + BackendProcessExitStatusError, +]); +export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly readinessTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onReady?: () => Effect.Effect; - readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, @@ -183,11 +225,10 @@ const closeRun = ( ).pipe(Effect.ignore); }; -const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( - baseUrl: URL, - timeout: Duration.Duration, -): Effect.fn.Return { - const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); +export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( + options: BackendProcessContext & { readonly timeout: Duration.Duration }, +): Effect.fn.Return { + const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); const client = (yield* HttpClient.HttpClient).pipe( HttpClient.filterStatusOk, HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), @@ -196,29 +237,22 @@ const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(fu yield* client.get(readinessUrl).pipe( Effect.asVoid, - Effect.timeout(timeout), - Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), + Effect.timeout(options.timeout), + Effect.mapError( + (cause) => + new BackendReadinessTimeoutError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + readinessUrl, + timeoutMs: Duration.toMillis(options.timeout), + cause, + }), + ), ); }); -function describeProcessExit( - result: Result.Result, -): BackendProcessExit { - if (Result.isSuccess(result)) { - return { - code: Option.some(result.success), - reason: `code=${result.success}`, - result, - }; - } - - return { - code: Option.none(), - reason: result.failure.message, - result, - }; -} - function drainBackendOutput( streamName: BackendProcessOutputStream, stream: Stream.Stream, @@ -232,7 +266,7 @@ function drainBackendOutput( const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); -const runBackendProcess = Effect.fn("runBackendProcess")(function* ( +export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -240,7 +274,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.mapError( (cause) => new BackendProcessBootstrapEncodeError({ - detail: cause.message, + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, cause, }), ), @@ -273,7 +310,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.mapError( (cause) => new BackendProcessSpawnError({ - detail: cause.message, + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, cause, }), ), @@ -284,16 +324,37 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); } - yield* waitForHttpReady( - options.httpBaseUrl, - options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - ).pipe( + yield* waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, + }), Effect.forkScoped, ); - return describeProcessExit(yield* Effect.result(handle.exitCode)); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError( + (cause) => + new BackendProcessExitStatusError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + cause, + }), + ), + ); + return { + code: Option.some(exitCode), + reason: `code=${exitCode}`, + } satisfies BackendProcessExit; }); export const make = Effect.gen(function* () { @@ -351,7 +412,7 @@ export const make = Effect.gen(function* () { const config = yield* configuration.resolve.pipe( Effect.tapError((error) => logBackendManagerError("failed to generate desktop backend configuration", { - cause: error.message, + cause: error, }), ), Effect.option, @@ -489,14 +550,14 @@ export const make = Effect.gen(function* () { yield* desktopWindow.handleBackendReady.pipe( Effect.catch((error) => logBackendManagerError("failed to open main window after backend readiness", { - message: error.message, + cause: error, }), ), ); }), onReadinessFailure: (error) => logBackendManagerWarning("backend readiness check failed during bootstrap", { - error: error.message, + error, }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( @@ -504,7 +565,10 @@ export const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => finalizeRun(error.message), + onFailure: (error) => + logBackendManagerError(error.message, { error }).pipe( + Effect.andThen(finalizeRun(error.message)), + ), onSuccess: (exit) => finalizeRun(exit.reason), }), Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), @@ -559,11 +623,17 @@ export const make = Effect.gen(function* () { }), ), Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), - Effect.catchCause((cause) => - logBackendManagerError("desktop backend restart fiber failed", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopBackendRestartError({ + reason, + delayMs: Duration.toMillis(delay), + cause, + }); + return logBackendManagerError(error.message, { error }); + }), ), parentScope, ); From a26f0dc89fd46f88529823511cdd068c9cd2c151 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:56:46 -0700 Subject: [PATCH 204/257] [codex] structure source-control repository failures (#3336) Co-authored-by: codex --- .../SourceControlRepositoryService.test.ts | 87 ++++++++++++++++++- .../SourceControlRepositoryService.ts | 56 ++++-------- 2 files changed, 98 insertions(+), 45 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index c792480b7fc..861da9a10e0 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,11 +1,13 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; +import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -54,8 +56,9 @@ function processOutput(): GitVcsDriver.ExecuteGitResult { function makeLayer(input: { readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; readonly git?: Partial; + readonly fileSystem?: FileSystem.FileSystem; }) { - return SourceControlRepositoryService.layer.pipe( + const serviceLayer = SourceControlRepositoryService.layer.pipe( Layer.provide( Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ get: () => Effect.succeed(input.provider ?? makeProvider()), @@ -75,9 +78,20 @@ function makeLayer(input: { ...input.git, }), ), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provide( + ServerConfig.layerTest( + process.cwd(), + input.fileSystem ? "/tmp/t3-source-control-repos" : { prefix: "t3-source-control-repos-" }, + ), + ), ); + + return input.fileSystem + ? serviceLayer.pipe( + Layer.provide(Layer.succeed(FileSystem.FileSystem, input.fileSystem)), + Layer.provideMerge(NodePath.layer), + ) + : serviceLayer.pipe(Layer.provideMerge(NodeServices.layer)); } it.effect("looks up repositories through the requested provider without search", () => { @@ -103,6 +117,39 @@ it.effect("looks up repositories through the requested provider without search", }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("preserves provider failures without deriving the repository message from them", () => { + const providerCause = new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: "/workspace", + repository: "octocat/t3code", + detail: "credential token abc123 was rejected", + }); + const provider = makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(providerCause), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.lookupRepository({ + provider: "github", + repository: "octocat/t3code", + cwd: "/workspace", + }), + ); + + assert.strictEqual(error.provider, "github"); + assert.strictEqual(error.operation, "lookupRepository"); + assert.strictEqual(error.detail, "The source control operation could not be completed."); + assert.strictEqual( + error.message, + "Source control repository operation lookupRepository failed for github: The source control operation could not be completed.", + ); + assert.strictEqual(error.cause, providerCause); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -148,6 +195,38 @@ it.effect("clones a looked-up repository into the requested destination", () => }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect("preserves destination probe failures instead of treating them as missing paths", () => { + const fileSystemCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: "/restricted/t3code", + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.cloneRepository({ + remoteUrl: CLONE_URLS.sshUrl, + destinationPath: "/restricted/t3code", + }), + ); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "cloneRepository"); + assert.strictEqual(error.cause, fileSystemCause); + }).pipe( + Effect.provide( + makeLayer({ + fileSystem: FileSystem.makeNoop({ + exists: () => Effect.fail(fileSystemCause), + makeDirectory: () => Effect.void, + }), + }), + ), + ); +}); + it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index ff88a4c3146..1b46369e25c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -39,41 +39,14 @@ export class SourceControlRepositoryService extends Context.Service< } >()("t3/sourceControl/SourceControlRepositoryService") {} -function detailFromUnknown(cause: unknown): string { - if (typeof cause === "object" && cause !== null) { - if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) { - return cause.detail; - } - if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) { - return cause.message; - } - } - - return "An unexpected source control error occurred."; -} - -function repositoryError(input: { - readonly operation: string; - readonly provider: SourceControlProviderKind; - readonly detail: string; - readonly cause?: unknown; -}): SourceControlRepositoryError { - return new SourceControlRepositoryError({ - provider: input.provider, - operation: input.operation, - detail: input.detail, - ...(input.cause === undefined ? {} : { cause: input.cause }), - }); -} - function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { return Effect.mapError((cause: unknown) => isSourceControlRepositoryError(cause) ? cause - : repositoryError({ + : new SourceControlRepositoryError({ operation, provider, - detail: detailFromUnknown(cause), + detail: "The source control operation could not be completed.", cause, }), ); @@ -130,7 +103,7 @@ export const make = Effect.gen(function* () { } return Effect.fail( - repositoryError({ + new SourceControlRepositoryError({ operation: input.operation, provider: input.provider, detail: "Choose a source control provider before continuing.", @@ -157,7 +130,7 @@ export const make = Effect.gen(function* () { function* (destinationPath: string) { const trimmed = destinationPath.trim(); if (trimmed.length === 0) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider: "unknown", detail: "Choose a destination path before cloning.", @@ -171,21 +144,22 @@ export const make = Effect.gen(function* () { const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( function* (destinationPath: string) { const normalizedDestination = yield* normalizeDestinationPath(destinationPath); - if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) { + if (yield* fileSystem.exists(normalizedDestination)) { const entries = yield* fileSystem .readDirectory(normalizedDestination, { recursive: false }) .pipe( - Effect.mapError((cause) => - repositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Destination path already exists and is not a directory.", - cause, - }), + Effect.mapError( + (cause) => + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not a directory.", + cause, + }), ), ); if (entries.length > 0) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider: "unknown", detail: "Destination path already exists and is not empty.", @@ -222,7 +196,7 @@ export const make = Effect.gen(function* () { } if (!remoteUrl) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider, detail: "Enter a repository path or clone URL before cloning.", From 57d25c934b74986423896a7dc433595ed09b9c8d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:15:16 -0700 Subject: [PATCH 205/257] [codex] Structure persistence error correlation (#3439) Co-authored-by: codex --- .../src/persistence/AuthPairingLinks.ts | 84 +++++- apps/server/src/persistence/AuthSessions.ts | 70 ++++- apps/server/src/persistence/Errors.ts | 17 +- .../src/persistence/ProviderSessionRuntime.ts | 59 ++-- .../RepositoryErrorCorrelation.test.ts | 253 ++++++++++++++++++ 5 files changed, 451 insertions(+), 32 deletions(-) create mode 100644 apps/server/src/persistence/RepositoryErrorCorrelation.test.ts diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index c29b023d1d8..e54c977e7ab 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -11,6 +11,7 @@ import { AuthEnvironmentScopes } from "@t3tools/contracts"; import { type AuthPairingLinkRepositoryError, PersistenceDecodeError, + type PersistenceErrorCorrelation, PersistenceSqlError, } from "./Errors.ts"; @@ -66,6 +67,22 @@ export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ }); export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; +const AuthPairingLinkRawDbRow = Schema.Struct({ + id: Schema.String, + credential: Schema.Unknown, + method: Schema.Unknown, + scopes: Schema.Unknown, + subject: Schema.Unknown, + label: Schema.Unknown, + proofKeyThumbprint: Schema.Unknown, + createdAt: Schema.Unknown, + expiresAt: Schema.Unknown, + consumedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthPairingLinkDbRow = Schema.decodeUnknownEffect(AuthPairingLinkRecord); + export class AuthPairingLinkRepository extends Context.Service< AuthPairingLinkRepository, { @@ -87,11 +104,19 @@ export class AuthPairingLinkRepository extends Context.Service< } >()("t3/persistence/AuthPairingLinks/AuthPairingLinkRepository") {} -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthPairingLinkRepositoryError => Schema.isSchemaError(cause) - ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) - : new PersistenceSqlError({ operation: sqlOperation, cause }); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } export const make = Effect.gen(function* () { @@ -132,7 +157,7 @@ export const make = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => sql` UPDATE auth_pairing_links @@ -162,7 +187,7 @@ export const make = Effect.gen(function* () { const listActivePairingLinkRows = SqlSchema.findAll({ Request: ListActiveAuthPairingLinksInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ now }) => sql` SELECT @@ -201,7 +226,7 @@ export const make = Effect.gen(function* () { const getPairingLinkRowByCredential = SqlSchema.findOneOption({ Request: GetAuthPairingLinkByCredentialInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ credential }) => sql` SELECT @@ -227,6 +252,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.create:query", "AuthPairingLinkRepository.create:encodeRequest", + { pairingLinkId: input.id }, ), ), ); @@ -239,6 +265,22 @@ export const make = Effect.gen(function* () { "AuthPairingLinkRepository.consumeAvailable:decodeRow", ), ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), ); const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) => @@ -249,6 +291,19 @@ export const make = Effect.gen(function* () { "AuthPairingLinkRepository.listActive:decodeRows", ), ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.listActive:decodeRows", + cause, + { pairingLinkId: row.id }, + ), + ), + ), + ), + ), ); const revoke: AuthPairingLinkRepository["Service"]["revoke"] = (input) => @@ -257,6 +312,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.revoke:query", "AuthPairingLinkRepository.revoke:decodeRows", + { pairingLinkId: input.id }, ), ), Effect.map((rows) => rows.length > 0), @@ -270,6 +326,22 @@ export const make = Effect.gen(function* () { "AuthPairingLinkRepository.getByCredential:decodeRow", ), ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.getByCredential:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), ); return { diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 17f76042d0a..545688e3822 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -16,6 +16,7 @@ import { import { type AuthSessionRepositoryError, PersistenceDecodeError, + type PersistenceErrorCorrelation, PersistenceSqlError, } from "./Errors.ts"; @@ -122,6 +123,25 @@ const AuthSessionDbRow = Schema.Struct({ revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), }); +const AuthSessionRawDbRow = Schema.Struct({ + sessionId: Schema.String, + subject: Schema.Unknown, + scopes: Schema.Unknown, + method: Schema.Unknown, + clientLabel: Schema.Unknown, + clientIpAddress: Schema.Unknown, + clientUserAgent: Schema.Unknown, + clientDeviceType: Schema.Unknown, + clientOs: Schema.Unknown, + clientBrowser: Schema.Unknown, + issuedAt: Schema.Unknown, + expiresAt: Schema.Unknown, + lastConnectedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow); + function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord { return { sessionId: row.sessionId, @@ -143,11 +163,19 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco }; } -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthSessionRepositoryError => Schema.isSchemaError(cause) - ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) - : new PersistenceSqlError({ operation: sqlOperation, cause }); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } export const make = Effect.gen(function* () { @@ -192,7 +220,7 @@ export const make = Effect.gen(function* () { const getSessionRowById = SqlSchema.findOneOption({ Request: GetAuthSessionByIdInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ sessionId }) => sql` SELECT @@ -217,7 +245,7 @@ export const make = Effect.gen(function* () { const listActiveSessionRows = SqlSchema.findAll({ Request: ListActiveAuthSessionsInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ now }) => sql` SELECT @@ -285,6 +313,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.create:query", "AuthSessionRepository.create:encodeRequest", + { sessionId: input.sessionId }, ), ), ); @@ -295,12 +324,23 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.getById:query", "AuthSessionRepository.getById:decodeRow", + { sessionId: input.sessionId }, ), ), Effect.flatMap((rowOption) => Option.match(rowOption, { onNone: () => Effect.succeed(Option.none()), - onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))), + onSome: (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.getById:decodeRow", + cause, + { sessionId: input.sessionId }, + ), + ), + Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))), + ), }), ), ); @@ -313,7 +353,20 @@ export const make = Effect.gen(function* () { "AuthSessionRepository.listActive:decodeRows", ), ), - Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.listActive:decodeRows", + cause, + { sessionId: row.sessionId }, + ), + ), + Effect.map(toAuthSessionRecord), + ), + ), + ), ); const revoke: AuthSessionRepository["Service"]["revoke"] = (input) => @@ -322,6 +375,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.revoke:query", "AuthSessionRepository.revoke:decodeRows", + { sessionId: input.sessionId }, ), ), Effect.map((rows) => rows.length > 0), @@ -333,6 +387,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.revokeAllExcept:query", "AuthSessionRepository.revokeAllExcept:decodeRows", + { currentSessionId: input.currentSessionId }, ), ), Effect.map((rows) => rows.map((row) => row.sessionId)), @@ -344,6 +399,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.setLastConnectedAt:query", "AuthSessionRepository.setLastConnectedAt:encodeRequest", + { sessionId: input.sessionId }, ), ), ); diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index e7d081c8f72..03edaec77d6 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -19,11 +19,20 @@ function summarizeSchemaIssue(issue: SchemaIssue.Issue): string { // Core Persistence Errors // =============================== +export const PersistenceErrorCorrelation = Schema.Union([ + Schema.Struct({ sessionId: Schema.String }), + Schema.Struct({ currentSessionId: Schema.String }), + Schema.Struct({ pairingLinkId: Schema.String }), + Schema.Struct({ threadId: Schema.String }), +]); +export type PersistenceErrorCorrelation = typeof PersistenceErrorCorrelation.Type; + export class PersistenceSqlError extends Schema.TaggedErrorClass()( "PersistenceSqlError", { operation: Schema.String, detail: Schema.optional(Schema.String), + correlation: Schema.optional(PersistenceErrorCorrelation), cause: Schema.optional(Schema.Defect()), }, ) { @@ -39,13 +48,19 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass Schema.isSchemaError(cause) - ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) - : new PersistenceSqlError({ operation: sqlOperation, cause }); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } export const make = Effect.gen(function* () { @@ -165,7 +186,7 @@ export const make = Effect.gen(function* () { const getRuntimeRowByThreadId = SqlSchema.findOneOption({ Request: GetRuntimeRequestSchema, - Result: ProviderSessionRuntimeDbRowSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, execute: ({ threadId }) => sql` SELECT @@ -185,7 +206,7 @@ export const make = Effect.gen(function* () { const listRuntimeRows = SqlSchema.findAll({ Request: Schema.Void, - Result: ProviderSessionRuntimeDbRowSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, execute: () => sql` SELECT @@ -218,6 +239,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", "ProviderSessionRuntimeRepository.upsert:encodeRequest", + { threadId: runtime.threadId }, ), ), ); @@ -228,17 +250,19 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.getByThreadId:query", "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + { threadId: input.threadId }, ), ), Effect.flatMap((runtimeRowOption) => Option.match(runtimeRowOption, { onNone: () => Effect.succeed(Option.none()), onSome: (row) => - decodeRuntime(row).pipe( + decodeRuntimeRow(row).pipe( Effect.mapError((cause) => PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", cause, + { threadId: input.threadId }, ), ), Effect.map((runtime) => Option.some(runtime)), @@ -256,18 +280,16 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => - decodeRuntime(row).pipe( - Effect.mapError((cause) => - PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.list:rowToRuntime", - cause, - ), + Effect.forEach(rows, (row) => + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, ), ), - { concurrency: "unbounded" }, + ), ), ), ); @@ -280,6 +302,7 @@ export const make = Effect.gen(function* () { (cause) => new PersistenceSqlError({ operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query", + correlation: { threadId: input.threadId }, cause, }), ), diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts new file mode 100644 index 00000000000..f7425200fd1 --- /dev/null +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -0,0 +1,253 @@ +import { AuthSessionId, ThreadId, type AuthEnvironmentScope } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as AuthPairingLinks from "./AuthPairingLinks.ts"; +import * as AuthSessions from "./AuthSessions.ts"; +import * as PersistenceErrors from "./Errors.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts"; + +const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); +const expiresAt = DateTime.makeUnsafe("2027-06-20T00:00:00.000Z"); +const now = DateTime.makeUnsafe("2026-06-21T00:00:00.000Z"); +const scopes: ReadonlyArray = ["access:read"]; + +const authSessionLayer = AuthSessions.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const authPairingLinkLayer = AuthPairingLinks.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); +const providerSessionRuntimeLayer = ProviderSessionRuntime.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); + +describe("persistence error correlation", () => { + it.effect("correlates auth session SQL and row-decode failures without sensitive fields", () => + Effect.gen(function* () { + const sessions = yield* AuthSessions.AuthSessionRepository; + const sql = yield* SqlClient.SqlClient; + const sessionId = AuthSessionId.make("session-correlation"); + const currentSessionId = AuthSessionId.make("current-session-correlation"); + const subject = "session-subject-secret-sentinel"; + + yield* sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }); + yield* sql` + UPDATE auth_sessions + SET scopes = ${"session-scopes-secret-sentinel"} + WHERE session_id = ${sessionId} + `; + + const decodeError = yield* Effect.flip(sessions.listActive({ now })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { sessionId }); + assert.equal( + decodeError.message, + `Decode error in AuthSessionRepository.listActive:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, "session-scopes-secret-sentinel"); + assert.notInclude(decodeError.message, subject); + + yield* sql`DROP TABLE auth_sessions`; + const createError = yield* Effect.flip( + sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { sessionId }); + assert.equal(createError.message, "SQL error in AuthSessionRepository.create:query"); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeOtherError = yield* Effect.flip( + sessions.revokeAllExcept({ currentSessionId, revokedAt: now }), + ); + assert.instanceOf(revokeOtherError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeOtherError.correlation, { currentSessionId }); + assert.equal( + revokeOtherError.message, + "SQL error in AuthSessionRepository.revokeAllExcept:query", + ); + assert.notInclude(revokeOtherError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authSessionLayer)), + ); + + it.effect("correlates pairing-link create and revoke failures by id only", () => + Effect.gen(function* () { + const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; + const sql = yield* SqlClient.SqlClient; + const id = "pairing-link-correlation"; + const credential = "pairing-credential-secret-sentinel"; + const subject = "pairing-subject-secret-sentinel"; + const scopesPayload = "pairing-scopes-secret-sentinel"; + + yield* sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + scopes, + subject, + label, + proof_key_thumbprint, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${id}, + ${credential}, + ${"one-time-token"}, + ${scopesPayload}, + ${subject}, + NULL, + NULL, + ${DateTime.formatIso(issuedAt)}, + ${DateTime.formatIso(expiresAt)}, + NULL, + NULL + ) + `; + + const decodeError = yield* Effect.flip(pairingLinks.getByCredential({ credential })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { pairingLinkId: id }); + assert.equal( + decodeError.message, + `Decode error in AuthPairingLinkRepository.getByCredential:decodeRow: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, credential); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, scopesPayload); + assert.notInclude(decodeError.message, DateTime.formatIso(issuedAt)); + + yield* sql`DROP TABLE auth_pairing_links`; + const createError = yield* Effect.flip( + pairingLinks.create({ + id, + credential, + method: "one-time-token", + scopes, + subject, + label: null, + proofKeyThumbprint: null, + createdAt: issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { pairingLinkId: id }); + assert.notInclude(createError.message, credential); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeError = yield* Effect.flip(pairingLinks.revoke({ id, revokedAt: now })); + assert.instanceOf(revokeError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeError.correlation, { pairingLinkId: id }); + assert.notInclude(revokeError.message, credential); + assert.notInclude(revokeError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authPairingLinkLayer)), + ); + + it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + Effect.gen(function* () { + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-correlation"); + const runtimePayload = "runtime-payload-secret-sentinel"; + const lastSeenAt = "2026-06-20T00:00:00.000Z"; + + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${threadId}, + ${"codex"}, + NULL, + ${"codex"}, + ${"invalid-runtime-mode"}, + ${"running"}, + ${lastSeenAt}, + NULL, + ${`{"secret":"${runtimePayload}"}`} + ) + `; + + const decodeError = yield* Effect.flip(runtimes.list()); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { threadId }); + assert.equal( + decodeError.message, + `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, runtimePayload); + assert.notInclude(decodeError.message, runtimePayload); + assert.notInclude(decodeError.message, lastSeenAt); + + yield* sql`DROP TABLE provider_session_runtime`; + const sqlFailure = yield* Effect.flip( + runtimes.upsert({ + threadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: { secret: runtimePayload }, + }), + ); + assert.instanceOf(sqlFailure, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(sqlFailure.correlation, { threadId }); + assert.equal( + sqlFailure.message, + "SQL error in ProviderSessionRuntimeRepository.upsert:query", + ); + assert.notInclude(sqlFailure.message, runtimePayload); + assert.notInclude(sqlFailure.message, lastSeenAt); + }).pipe(Effect.provide(providerSessionRuntimeLayer)), + ); +}); From 90dc76b11eac811e8bc89cad3f60bb5c7b56514f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:15:22 -0700 Subject: [PATCH 206/257] Preserve asset access failure causes (#3342) Co-authored-by: codex --- apps/server/src/assets/AssetAccess.test.ts | 87 ++++++- apps/server/src/assets/AssetAccess.ts | 225 ++++++++++++++---- .../project/ProjectFaviconResolver.test.ts | 120 ++++++++++ .../src/project/ProjectFaviconResolver.ts | 123 ++++++++-- apps/server/src/ws.ts | 8 + packages/contracts/src/assets.ts | 18 ++ 6 files changed, 512 insertions(+), 69 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0cbe9176582..7df2e3361c8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.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 Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -85,7 +86,58 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }).pipe(Effect.flip); - expect(error.message).toContain("relative to the project root"); + expect(error.message).toBe("Workspace file path must be relative to the project root."); + expect(error).toMatchObject({ + operation: "validate-workspace-path", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("preserves non-missing canonical path failures when issuing asset URLs", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-permission-root-", + }); + const htmlPath = path.join(root, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "

report

"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: htmlPath, + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + realPath: () => Effect.fail(cause), + }); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip); + + expect(error.message).toBe("Failed to inspect the workspace asset."); + expect(error).toMatchObject({ + operation: "inspect-workspace-asset", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); + expect(error.cause).toBe(cause); }).pipe(Effect.provide(testLayer)), ); @@ -186,4 +238,37 @@ describe("AssetAccess", () => { ).toEqual({ kind: "project-favicon-fallback" }); }).pipe(Effect.provide(testLayer)), ); + + it.effect("preserves structured project favicon resolution causes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-error-", + }); + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + }); + const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: root, + relativePath: "favicon.svg", + cause: platformCause, + }); + const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolvePath: () => Effect.fail(resolutionCause), + }); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe( + Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver), + Effect.flip, + ); + + expect(error.message).toBe("Failed to resolve project favicon."); + expect(error.cause).toBe(resolutionCause); + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 873e9fc3d37..f7be262b41a 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { @@ -97,33 +98,59 @@ function decodeRelativePath(value: string): string | null { } } -const failAccess = (message: string, cause?: unknown) => - new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none
()) : Effect.fail(error), + }), + ); const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const resolved = yield* workspacePaths - .resolveRelativePathWithinRoot(input) - .pipe(Effect.orElseSucceed(() => null)); - if (!resolved) return null; + const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => Effect.succeed(Option.none()), + }), + ); + if (Option.isNone(resolved)) return null; const [canonicalRoot, canonicalFile] = yield* Effect.all([ - fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)), - fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)), + optionOnNotFound(fileSystem.realPath(input.workspaceRoot)), + optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)), ]); - if (!canonicalRoot || !canonicalFile) return null; + if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null; const path = yield* Path.Path; - const relative = path.relative(canonicalRoot, canonicalFile); + const relative = path.relative(canonicalRoot.value, canonicalFile.value); if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null; - const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null)); - return info?.type === "File" ? canonicalFile : null; + const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value)); + return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; }, ); +const resolveCanonicalWorkspaceFileForRequest = (input: { + readonly workspaceRoot: string; + readonly relativePath: string; +}) => + resolveCanonicalWorkspaceFile(input).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical asset path.", { + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; @@ -138,30 +165,78 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { - return yield* failAccess("Workspace context was not found."); + return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, + message: "Workspace context was not found.", + }); } - const workspaceRoot = yield* workspacePaths - .normalizeWorkspaceRoot(input.workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "normalize-workspace-root", + resource: input.resource, + message: "Failed to normalize the workspace root.", + cause, + }), + ), + ); const relativePath = path.isAbsolute(input.resource.path) ? path.relative(workspaceRoot, input.resource.path) : input.resource.path; const resolved = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "validate-workspace-path", + resource: input.resource, + message: "Workspace file path must be relative to the project root.", + cause, + }), + ), + ); if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { - return yield* failAccess("Only browser documents and images can be previewed."); + return yield* new AssetAccessError({ + operation: "validate-preview-type", + resource: input.resource, + message: "Only browser documents and images can be previewed.", + }); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: resolved.relativePath, - }); + }).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "inspect-workspace-asset", + resource: input.resource, + message: "Failed to inspect the workspace asset.", + cause, + }), + ), + ); if (!canonicalFile) { - return yield* failAccess("Workspace asset was not found."); + return yield* new AssetAccessError({ + operation: "locate-workspace-asset", + resource: input.resource, + message: "Workspace asset was not found.", + }); } - const canonicalWorkspaceRoot = yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))); + const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "resolve-workspace", + resource: input.resource, + message: "Failed to resolve workspace.", + cause, + }), + ), + ); claims = isWorkspaceImagePreviewPath(resolved.relativePath) ? { version: 1, @@ -187,7 +262,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { - return yield* failAccess("Attachment was not found."); + return yield* new AssetAccessError({ + operation: "locate-attachment", + resource: input.resource, + message: "Attachment was not found.", + }); } claims = { version: 1, @@ -199,24 +278,64 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i break; } case "project-favicon": { - const workspaceRoot = yield* workspacePaths - .normalizeWorkspaceRoot(input.resource.cwd) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "normalize-workspace-root", + resource: input.resource, + message: "Failed to normalize the workspace root.", + cause, + }), + ), + ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); + const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "resolve-project-favicon", + resource: input.resource, + message: "Failed to resolve project favicon.", + cause, + }), + ), + ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if ( relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath })) + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "inspect-project-favicon", + resource: input.resource, + message: "Failed to inspect the project favicon.", + cause, + }), + ), + )) ) { - return yield* failAccess("Project favicon was not found."); + return yield* new AssetAccessError({ + operation: "locate-project-favicon", + resource: input.resource, + message: "Project favicon was not found.", + }); } claims = { version: 1, kind: "project-favicon", - workspaceRoot: yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "resolve-workspace", + resource: input.resource, + message: "Failed to resolve workspace.", + cause, + }), + ), + ), relativePath, expiresAt, }; @@ -226,9 +345,17 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore - .getOrCreateRandom(SIGNING_SECRET_NAME, 32) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "load-signing-key", + resource: input.resource, + message: "Failed to load the asset signing key.", + cause, + }), + ), + ); const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { @@ -245,9 +372,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!encodedPayload || !signature) return null; const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore - .getOrCreateRandom(SIGNING_SECRET_NAME, 32) - .pipe(Effect.orElseSucceed(() => null)); + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })), + Effect.orElseSucceed(() => null), + ); if (!signingSecret) return null; if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; @@ -262,8 +390,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( }); if (!attachmentPath) return null; const fileSystem = yield* FileSystem.FileSystem; - const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null)); - return info?.type === "File" + const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to inspect attachment asset.", { + attachmentId: claims.attachmentId, + path: attachmentPath, + cause, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + return Option.isSome(info) && info.value.type === "File" ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) : null; } @@ -272,7 +409,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (claims.relativePath === null) { return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; } - const faviconPath = yield* resolveCanonicalWorkspaceFile({ + const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); @@ -284,7 +421,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const path = yield* Path.Path; if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; - const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({ + const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); @@ -303,7 +440,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( } const joinedRelativePath = claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); - const workspaceFile = yield* resolveCanonicalWorkspaceFile({ + const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: joinedRelativePath, }); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 37bda11e6aa..0b017b22e4e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.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 Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -34,6 +35,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => + ProjectFaviconResolver.make.pipe( + Effect.provide(WorkspacePaths.layer), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { it.effect("prefers well-known favicon files", () => @@ -73,5 +80,118 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toBeNull(); }), ); + + it.effect("preserves workspace normalization context", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const missingCwd = `${cwd}/missing`; + + const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "normalize-workspace", + workspaceRoot: missingCwd, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError); + }), + ); + + it.effect("preserves non-missing candidate stat failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const faviconPath = path.join(cwd, "favicon.svg"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: faviconPath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "stat-candidate", + workspaceRoot: cwd, + relativePath: "favicon.svg", + absolutePath: faviconPath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("preserves icon source read failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const sourcePath = path.join(cwd, "index.html"); + yield* writeTextFile(cwd, "index.html", ''); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: sourcePath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (filePath, options) => + filePath === sourcePath + ? Effect.fail(cause) + : fileSystem.readFileString(filePath, options), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "read-source", + workspaceRoot: cwd, + relativePath: "index.html", + absolutePath: sourcePath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("skips icon metadata paths outside the workspace", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + + it.effect("continues to later sources after an outside-root icon href", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + yield* writeTextFile(cwd, "public/index.html", ''); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 4c685a20f88..e644df06ae6 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -10,7 +10,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -56,6 +59,26 @@ const LINK_ICON_HTML_RE = const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "ProjectFaviconResolutionError", + { + operation: Schema.Literals([ + "normalize-workspace", + "resolve-path", + "stat-candidate", + "read-source", + ]), + workspaceRoot: Schema.String, + relativePath: Schema.optional(Schema.String), + absolutePath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`; + } +} + /** Service tag for project favicon resolution. */ export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, @@ -65,7 +88,9 @@ export class ProjectFaviconResolver extends Context.Service< * * Returns `null` when no candidate icon file can be found. */ - readonly resolvePath: (cwd: string) => Effect.Effect; + readonly resolvePath: ( + cwd: string, + ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -77,6 +102,17 @@ function extractIconHref(source: string): string | null { return null; } +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); + export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -90,22 +126,39 @@ export const make = Effect.gen(function* () { const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, - ): Effect.fn.Return { + ): Effect.fn.Return { for (const relativePath of relativeCandidates) { const candidate = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot: projectCwd, relativePath, }) - .pipe(Effect.orElseSucceed(() => null)); - if (!candidate) { + .pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => + Effect.succeed( + Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), + ), + }), + ); + if (Option.isNone(candidate)) { continue; } - const stats = yield* fileSystem - .stat(candidate.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (stats?.type === "File") { - return candidate.absolutePath; + const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: projectCwd, + relativePath, + absolutePath: candidate.value.absolutePath, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { + return candidate.value.absolutePath; } } return null; @@ -114,12 +167,16 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd) { - const projectCwd = yield* workspacePaths - .normalizeWorkspaceRoot(cwd) - .pipe(Effect.orElseSucceed(() => null)); - if (!projectCwd) { - return null; - } + const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot: cwd, + cause, + }), + ), + ); for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { @@ -133,17 +190,35 @@ export const make = Effect.gen(function* () { workspaceRoot: projectCwd, relativePath: sourceFile, }) - .pipe(Effect.orElseSucceed(() => null)); - if (!sourcePath) { - continue; - } - const source = yield* fileSystem - .readFileString(sourcePath.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (!source) { + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath: sourceFile, + cause, + }), + ), + ); + const source = yield* optionOnNotFound( + fileSystem.readFileString(sourcePath.absolutePath), + ).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "read-source", + workspaceRoot: projectCwd, + relativePath: sourceFile, + absolutePath: sourcePath.absolutePath, + cause, + }), + ), + ); + if (Option.isNone(source)) { continue; } - const href = extractIconHref(source); + const href = extractIconHref(source.value); if (!href) { continue; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 05e78de476c..7ebc432038c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1414,6 +1414,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Failed to resolve workspace context.", cause, }), @@ -1421,6 +1423,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); if (Option.isNone(thread)) { return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Workspace context was not found.", }); } @@ -1430,6 +1434,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Failed to resolve workspace context.", cause, }), @@ -1437,6 +1443,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); if (Option.isNone(project)) { return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Workspace context was not found.", }); } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index bd1ac0a53ec..fdfbe64246e 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -29,9 +29,27 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +export const AssetAccessOperation = Schema.Literals([ + "resolve-workspace-context", + "normalize-workspace-root", + "validate-workspace-path", + "validate-preview-type", + "inspect-workspace-asset", + "locate-workspace-asset", + "resolve-workspace", + "locate-attachment", + "resolve-project-favicon", + "inspect-project-favicon", + "locate-project-favicon", + "load-signing-key", +]); +export type AssetAccessOperation = typeof AssetAccessOperation.Type; + export class AssetAccessError extends Schema.TaggedErrorClass()( "AssetAccessError", { + operation: AssetAccessOperation, + resource: AssetResource, message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, From 5edf7c56bafeeaf4d5af1a63f010dde45e6652dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:38:56 -0700 Subject: [PATCH 207/257] [codex] Preserve PR materialization failure chains (#3443) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 59 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 49 +++++++++++++++------ packages/contracts/src/git.ts | 17 ++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index bb7f91ffc39..c06915c51b9 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2757,6 +2757,65 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const missingForkDir = NodePath.join(repoDir, "missing-fork.git"); + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 93, + title: "Missing fork branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/93", + baseRefName: "main", + headRefName: "feature/missing-fork-branch", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: missingForkDir, + sshUrl: missingForkDir, + }, + }, + }, + }); + + const error = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "93", + mode: "worktree", + }).pipe(Effect.flip); + + if (error._tag !== "GitPullRequestMaterializationError") { + return yield* Effect.die(error); + } + expect(error).toMatchObject({ + cwd: repoDir, + pullRequestNumber: 93, + headRepository: "octocat/codething-mvp", + headBranch: "feature/missing-fork-branch", + localBranch: "t3code/pr-93/feature/missing-fork-branch", + }); + if (!(error.cause instanceof AggregateError)) { + return yield* Effect.die(error.cause); + } + expect(error.cause.errors).toHaveLength(2); + expect(error.cause.errors).toEqual([ + expect.objectContaining({ _tag: "GitCommandError" }), + expect.objectContaining({ _tag: "GitCommandError" }), + ]); + expect(error.cause.cause).toBe(error.cause.errors[0]); + }), + ); + it.effect("launches setup only when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 46da2e6c1f9..f1fb03e7e45 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -41,7 +41,7 @@ import { type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { GitManagerError } from "@t3tools/contracts"; +import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; @@ -631,9 +631,12 @@ export const make = Effect.gen(function* () { ) => configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.configurePullRequestHeadUpstream failed", { + cwd, + localBranch, + headBranch: pullRequest.headBranch, + cause: error, + }).pipe(Effect.asVoid), ), ); @@ -691,12 +694,30 @@ export const make = Effect.gen(function* () { localBranch = pullRequest.headBranch, ) => materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( - Effect.catch(() => - gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }), + Effect.catch((primaryCause) => + gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + Effect.mapError( + (fallbackCause) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause: new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), + }), + ), + ), ), ); const fileSystem = yield* FileSystem.FileSystem; @@ -1452,9 +1473,11 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.preparePullRequestThread setup script failed", { + threadId: input.threadId, + worktreePath, + cause: error, + }).pipe(Effect.asVoid), ), ); }; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 0f1f09729be..aa5cdf8432b 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -361,8 +361,25 @@ export class GitManagerError extends Schema.TaggedErrorClass()( } } +export class GitPullRequestMaterializationError extends Schema.TaggedErrorClass()( + "GitPullRequestMaterializationError", + { + cwd: TrimmedNonEmptyStringSchema, + pullRequestNumber: PositiveInt, + headRepository: Schema.NullOr(TrimmedNonEmptyStringSchema), + headBranch: TrimmedNonEmptyStringSchema, + localBranch: TrimmedNonEmptyStringSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to materialize pull request #${this.pullRequestNumber} branch ${this.headBranch} as ${this.localBranch}.`; + } +} + export const GitManagerServiceError = Schema.Union([ GitManagerError, + GitPullRequestMaterializationError, GitCommandError, SourceControlProviderError, TextGenerationError, From a9460bb772a8e25d39213ae62e73934152d44495 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:44:22 -0700 Subject: [PATCH 208/257] [codex] Structure pull request link failures (#3445) Co-authored-by: codex --- apps/web/src/components/GitActionsControl.tsx | 4 +- apps/web/src/lib/openPullRequestLink.test.ts | 30 ++++++++++++++ apps/web/src/lib/openPullRequestLink.ts | 40 ++++++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/openPullRequestLink.test.ts diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index af3f7b47286..c9816719452 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -88,6 +88,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { openPullRequestLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { gitCwd: string | null; @@ -1229,7 +1230,8 @@ export default function GitActionsControl({ }); return; } - void api.shell.openExternal(prUrl).catch((err: unknown) => { + void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => { + console.error(err); toastManager.add( stackedThreadToast({ type: "error", diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts new file mode 100644 index 00000000000..756e1ed6ad9 --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { openPullRequestLink, PullRequestLinkOpenError } from "./openPullRequestLink"; + +describe("openPullRequestLink", () => { + it("opens the requested pull request URL", async () => { + const openExternal = vi.fn(async () => undefined); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123"; + + await openPullRequestLink({ openExternal }, targetUrl); + + expect(openExternal).toHaveBeenCalledExactlyOnceWith(targetUrl); + }); + + it("reports bridge failures with a safe target origin", async () => { + const cause = new Error("desktop shell unavailable"); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123?token=secret"; + const openExternal = vi.fn(async () => Promise.reject(cause)); + + const result = openPullRequestLink({ openExternal }, targetUrl); + + await expect(result).rejects.toEqual( + new PullRequestLinkOpenError({ + targetOrigin: "https://github.com", + cause, + }), + ); + await expect(result).rejects.not.toHaveProperty("message", expect.stringContaining("secret")); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 899e5c38c58..acd3c5a062b 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,8 +1,45 @@ +import type { LocalApi } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { readLocalApi } from "../localApi"; +export class PullRequestLinkOpenError extends Schema.TaggedErrorClass()( + "PullRequestLinkOpenError", + { + targetOrigin: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(targetUrl: string, cause: unknown): PullRequestLinkOpenError { + let targetOrigin: string | null = null; + try { + targetOrigin = new URL(targetUrl).origin; + } catch { + // Keep malformed URLs out of diagnostics while preserving the open failure below. + } + return new PullRequestLinkOpenError({ targetOrigin, cause }); + } + + override get message(): string { + return this.targetOrigin === null + ? "Unable to open pull request link." + : `Unable to open pull request link at ${this.targetOrigin}.`; + } +} + +export async function openPullRequestLink( + shell: Pick, + targetUrl: string, +): Promise { + try { + await shell.openExternal(targetUrl); + } catch (cause) { + throw PullRequestLinkOpenError.fromCause(targetUrl, cause); + } +} + /** * Returns a click handler that opens a pull request URL in the system browser. * @@ -24,7 +61,8 @@ export function useOpenPrLink() { return; } - void api.shell.openExternal(prUrl).catch((error) => { + void openPullRequestLink(api.shell, prUrl).catch((error) => { + console.error(error); toastManager.add( stackedThreadToast({ type: "error", From 28e7c9ae17affc31e028a06cf33911fabe84d2cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:58:15 -0700 Subject: [PATCH 209/257] [codex] Structure mobile waitlist enrollment failures (#3446) Co-authored-by: codex --- .../cloud/CloudWaitlistEnrollment.tsx | 15 +++--- .../features/cloud/cloudWaitlistJoin.test.ts | 48 +++++++++++++++++++ .../src/features/cloud/cloudWaitlistJoin.ts | 46 ++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts create mode 100644 apps/mobile/src/features/cloud/cloudWaitlistJoin.ts diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx index 77b48d44b1e..4d5b5703329 100644 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThemeColor } from "../../lib/useThemeColor"; +import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { const { errors, fetchStatus, waitlist } = useWaitlist(); @@ -21,12 +22,14 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } setRequestError(null); try { - const { error } = await waitlist.join({ emailAddress: normalizedEmailAddress }); - if (error) { - setRequestError("Could not join the waitlist. Check your email address and try again."); - } - } catch { - setRequestError("Could not join the waitlist. Check your connection and try again."); + await joinCloudWaitlist(waitlist, normalizedEmailAddress); + } catch (error) { + console.error(error); + setRequestError( + error instanceof CloudWaitlistJoinRejectedError + ? "Could not join the waitlist. Check your email address and try again." + : "Could not join the waitlist. Check your connection and try again.", + ); } }; diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts new file mode 100644 index 00000000000..582cb40ffbf --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + CloudWaitlistJoinRejectedError, + CloudWaitlistJoinRequestError, + joinCloudWaitlist, +} from "./cloudWaitlistJoin"; + +describe("joinCloudWaitlist", () => { + it("submits the provided email address", async () => { + const join = vi.fn().mockResolvedValue({ error: null }); + + await joinCloudWaitlist({ join }, "person@example.com"); + + expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" }); + }); + + it("preserves Clerk rejection details without exposing the email address", async () => { + const cause = Object.assign(new Error("The enrollment was rejected."), { + code: "form_identifier_invalid", + }); + const join = vi.fn().mockResolvedValue({ error: cause }); + + const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError); + expect(failure).toMatchObject({ + code: "form_identifier_invalid", + cause, + }); + expect(String(failure)).not.toContain("secret@example.com"); + }); + + it("distinguishes request failures from rejected enrollments", async () => { + const cause = new Error("network unavailable"); + const join = vi.fn().mockRejectedValue(cause); + + const failure = await joinCloudWaitlist({ join }, "person@example.com").catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError); + expect(failure).toMatchObject({ cause }); + expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError); + }); +}); diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts new file mode 100644 index 00000000000..4a467a19e4b --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts @@ -0,0 +1,46 @@ +import * as Schema from "effect/Schema"; + +interface CloudWaitlistJoiner { + readonly join: (input: { emailAddress: string }) => Promise<{ + readonly error: { readonly code: string } | null; + }>; +} + +export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass()( + "CloudWaitlistJoinRejectedError", + { + code: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Cloud waitlist enrollment was rejected with code "${this.code}".`; + } +} + +export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass()( + "CloudWaitlistJoinRequestError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Cloud waitlist enrollment request failed."; + } +} + +export async function joinCloudWaitlist( + waitlist: CloudWaitlistJoiner, + emailAddress: string, +): Promise { + const result = await waitlist.join({ emailAddress }).catch((cause) => { + throw new CloudWaitlistJoinRequestError({ cause }); + }); + + if (result.error) { + throw new CloudWaitlistJoinRejectedError({ + code: result.error.code, + cause: result.error, + }); + } +} From 803c2b77387bbaf5f10319f8ac6e5742b68d9612 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:58:21 -0700 Subject: [PATCH 210/257] [codex] Preserve desktop backend output read failures (#3444) Co-authored-by: codex --- .../src/backend/DesktopBackendManager.test.ts | 91 +++++++++++++++++- .../src/backend/DesktopBackendManager.ts | 95 +++++++++++++++++-- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 0c083889f29..3c0a513c9b5 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -59,8 +59,8 @@ const configWithObservability: DesktopBackendBootstrapValue = { }; function makeProcess(options?: { - readonly stdout?: Stream.Stream; - readonly stderr?: Stream.Stream; + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; readonly exitCode?: Effect.Effect; readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; }): ChildProcessSpawner.ChildProcessHandle { @@ -389,6 +389,93 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("reports output stream failures with process and stream context", () => + Effect.gen(function* () { + const outputCause = PlatformError.systemError({ + _tag: "BadResource", + module: "ChildProcess", + method: "stdout", + description: "output-stream-secret-sentinel", + }); + const reported = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fail(outputCause), + exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + const error = yield* Deferred.await(reported); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + if (error._tag !== "BackendProcessOutputReadError") { + return assert.fail(`Expected output read error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.pid, 123); + assert.equal(error.streamName, "stdout"); + assert.strictEqual(error.cause, outputCause); + assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); + assert.notInclude(error.message, "output-stream-secret-sentinel"); + }), + ); + + it.effect("reports output handler failures separately from stream read failures", () => + Effect.gen(function* () { + const chunk = new TextEncoder().encode("backend output"); + const outputCause = new Error("output-handler-secret-sentinel"); + const reported = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.make(chunk), + exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + onOutput: () => Effect.fail(outputCause), + onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + const error = yield* Deferred.await(reported); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + if (error._tag !== "BackendProcessOutputHandlingError") { + return assert.fail(`Expected output handling error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.pid, 123); + assert.equal(error.streamName, "stdout"); + assert.equal(error.chunkByteLength, chunk.byteLength); + assert.strictEqual(error.cause, outputCause); + assert.equal( + error.message, + `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, + ); + assert.notInclude(error.message, "output-handler-secret-sentinel"); + }), + ); + it.effect("retries HTTP readiness before reporting the backend ready", () => Effect.gen(function* () { const requestUrls: Array = []; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 8a40c9d2153..d92f62d16b7 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -105,6 +105,39 @@ export class BackendProcessSpawnError extends Schema.TaggedErrorClass()( + "BackendProcessOutputReadError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + streamName: Schema.Literals(["stdout", "stderr"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`; + } +} + +export class BackendProcessOutputHandlingError extends Schema.TaggedErrorClass()( + "BackendProcessOutputHandlingError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + streamName: Schema.Literals(["stdout", "stderr"]), + chunkByteLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`; + } +} + +export type BackendProcessOutputError = + | BackendProcessOutputReadError + | BackendProcessOutputHandlingError; + export class BackendProcessExitStatusError extends Schema.TaggedErrorClass()( "BackendProcessExitStatusError", { @@ -146,7 +179,8 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, - ) => Effect.Effect; + ) => Effect.Effect; + readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect; } export interface DesktopBackendSnapshot { @@ -254,13 +288,41 @@ export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpRea }); function drainBackendOutput( + context: BackendProcessContext & { readonly pid: number }, streamName: BackendProcessOutputStream, stream: Stream.Stream, - onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect, + onOutput: ( + streamName: BackendProcessOutputStream, + chunk: Uint8Array, + ) => Effect.Effect, + onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect, ): Effect.Effect { return stream.pipe( - Stream.runForEach((chunk) => onOutput(streamName, chunk)), - Effect.ignore, + Stream.mapError( + (cause) => + new BackendProcessOutputReadError({ + ...context, + streamName, + cause, + }), + ), + Stream.runForEach((chunk) => + onOutput(streamName, chunk).pipe( + Effect.mapError( + (cause) => + new BackendProcessOutputHandlingError({ + ...context, + streamName, + chunkByteLength: chunk.byteLength, + cause, + }), + ), + ), + ), + Effect.catchTags({ + BackendProcessOutputReadError: onOutputFailure, + BackendProcessOutputHandlingError: onOutputFailure, + }), ); } @@ -321,8 +383,28 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* options.onStarted?.(handle.pid) ?? Effect.void; if (options.captureOutput) { - yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); - yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + const outputContext = { + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + }; + const onOutputFailure = options.onOutputFailure ?? (() => Effect.void); + yield* drainBackendOutput( + outputContext, + "stdout", + handle.stdout, + onOutput, + onOutputFailure, + ).pipe(Effect.forkScoped); + yield* drainBackendOutput( + outputContext, + "stderr", + handle.stderr, + onOutput, + onOutputFailure, + ).pipe(Effect.forkScoped); } yield* waitForHttpReady({ executablePath: options.executablePath, @@ -560,6 +642,7 @@ export const make = Effect.gen(function* () { error, }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onOutputFailure: (error) => logBackendManagerError(error.message, { error }), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), From 9243ead1c3eebb6316321012cf42a21737965a56 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:09:11 -0700 Subject: [PATCH 211/257] [codex] Preserve Linux icon resize fallback failures (#3447) Co-authored-by: codex --- scripts/build-desktop-artifact.test.ts | 76 ++++++++++++++++++++++++++ scripts/build-desktop-artifact.ts | 34 ++++++++++-- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index f8c354a8599..62823f7fc81 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -4,6 +4,9 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { BuildScriptError, @@ -14,6 +17,7 @@ import { InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, isMacPasskeySigningConfigurationError, + LinuxIconResizeError, MissingMacPasskeyProvisioningProfileError, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, @@ -27,11 +31,49 @@ import { resolveGitHubPublishConfig, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, + stageLinuxIconSize, STAGE_INSTALL_ARGS, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +function mockProcess(exitCode: number) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function iconResizeSpawnerLayer( + commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>, + exitCodes: ReadonlyArray, +) { + let commandIndex = 0; + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push({ + command: childProcess.command, + args: childProcess.args, + }); + return Effect.succeed(mockProcess(exitCodes[commandIndex++] ?? 0)); + }), + ); +} + it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("resolves the dedicated nightly updater channel from nightly versions", () => { assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); @@ -184,6 +226,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); }); + it.effect("preserves both Linux icon resize failures with structural context", () => { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + return Effect.gen(function* () { + const error = yield* stageLinuxIconSize("source.png", "target.png", 512, false).pipe( + Effect.provide(iconResizeSpawnerLayer(commands, [1, 2])), + Effect.flip, + ); + + assert.instanceOf(error, LinuxIconResizeError); + assert.equal(error.operation, "resize"); + assert.equal(error.iconSize, 512); + assert.equal(error.primaryTool, "magick"); + assert.equal(error.fallbackTool, "convert"); + assert.include(error.message, "512x512"); + assert.include(error.message, "`magick`"); + assert.include(error.message, "`convert`"); + assert.notInclude(error.message, "non-zero exit code"); + + assert.instanceOf(error.cause, AggregateError); + const aggregateCause = error.cause as AggregateError; + assert.lengthOf(aggregateCause.errors, 2); + assert.strictEqual(aggregateCause.cause, aggregateCause.errors[0]); + assert.instanceOf(aggregateCause.errors[0], BuildScriptError); + assert.instanceOf(aggregateCause.errors[1], BuildScriptError); + assert.include((aggregateCause.errors[0] as BuildScriptError).message, "magick linux icon"); + assert.include((aggregateCause.errors[1] as BuildScriptError).message, "convert linux icon"); + assert.deepStrictEqual( + commands.map(({ command }) => command), + ["magick", "convert"], + ); + }); + }); + it("derives macOS passkey signing configuration from the Clerk publishable key", () => { const configuration = resolveMacPasskeySigningConfiguration({ T3CODE_APPLE_TEAM_ID: "abc1234567", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 6f13783f2d1..f1d03f61509 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -142,6 +142,21 @@ export class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ } } +export class LinuxIconResizeError extends Schema.TaggedErrorClass()( + "LinuxIconResizeError", + { + operation: Schema.Literal("resize"), + iconSize: Schema.Int, + primaryTool: Schema.Literal("magick"), + fallbackTool: Schema.Literal("convert"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} the Linux desktop icon to ${this.iconSize}x${this.iconSize} with \`${this.primaryTool}\` or \`${this.fallbackTool}\`. Install ImageMagick so either tool is available.`; + } +} + const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), @@ -877,7 +892,7 @@ function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: }); } -function stageLinuxIconSize( +export function stageLinuxIconSize( sourcePng: string, targetPng: string, iconSize: number, @@ -890,13 +905,20 @@ function stageLinuxIconSize( ); return resize("magick").pipe( - Effect.catch(() => + Effect.catch((primaryCause) => resize("convert").pipe( Effect.mapError( - () => - new BuildScriptError({ - message: - "ImageMagick is required to generate Linux desktop icon sizes. Install ImageMagick so either `magick` or `convert` is available.", + (fallbackCause) => + new LinuxIconResizeError({ + operation: "resize", + iconSize, + primaryTool: "magick", + fallbackTool: "convert", + cause: new AggregateError( + [primaryCause, fallbackCause], + "Both Linux icon resize tool attempts failed.", + { cause: primaryCause }, + ), }), ), ), From 9f7861aadd257313efefdc690e440d23d771f315 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:09:17 -0700 Subject: [PATCH 212/257] [codex] Structure desktop update persistence errors (#3261) Co-authored-by: codex --- .../src/settings/DesktopAppSettings.ts | 67 +++-- .../src/updates/DesktopUpdates.test.ts | 262 ++++++++++++++++- apps/desktop/src/updates/DesktopUpdates.ts | 275 ++++++++++++++---- 3 files changed, 524 insertions(+), 80 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index e072d80f03e..81aae92f0a3 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -83,12 +83,6 @@ export class DesktopSettingsWriteError extends Schema.TaggedErrorClass new DesktopSettingsWriteError({ operation, path, cause }); - export class DesktopAppSettings extends Context.Service< DesktopAppSettings, { @@ -244,18 +238,46 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), - ).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)), - ); + ).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "encode-document", + path: input.settingsPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: input.settingsPath, + cause, + }), + ), + ); }); export const make = Effect.gen(function* () { @@ -276,8 +298,13 @@ export const make = Effect.gen(function* () { return crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError((cause) => - writeError("create-temporary-file-name", environment.desktopSettingsPath, cause), + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.desktopSettingsPath, + cause, + }), ), Effect.flatMap((suffix) => writeSettings({ diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ad234df0bb5..4c90afb2a12 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -7,7 +7,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; @@ -24,6 +27,9 @@ interface UpdatesHarnessOptions { void, ElectronUpdater.ElectronUpdaterCheckForUpdatesError >; + readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; + readonly setDisableDifferentialDownload?: Effect.Effect; + readonly stopBackend?: Effect.Effect; readonly env?: Record; } @@ -67,7 +73,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), - setDisableDifferentialDownload: () => Effect.void, + setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), @@ -103,7 +109,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, - stop: () => Effect.void, + stop: () => options.stopBackend ?? Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed({ desiredRunning: false, @@ -138,12 +144,23 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ), ); + const setUpdateChannelError = options.setUpdateChannelError; + const settingsLayer = setUpdateChannelError + ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.fail(setUpdateChannelError), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) + : DesktopAppSettings.layer; + const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(settingsLayer), Layer.provideMerge( DesktopConfig.layerTest({ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, @@ -175,6 +192,45 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { } describe("DesktopUpdates", () => { + it("preserves complete causes for update poller and event failures", () => { + const cause = Cause.combine( + Cause.fail(new Error("updater failed")), + Cause.die(new Error("updater defect")), + ); + const pollerError = new DesktopUpdates.DesktopUpdatePollerError({ + poller: "startup", + cause, + }); + const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({ + event: "download-progress", + cause, + }); + const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({ + operation: "download", + cause, + }); + const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({ + action: "install", + cause, + }); + + assert.strictEqual(pollerError.cause, cause); + assert.equal(pollerError.poller, "startup"); + assert.equal(pollerError.message, "Desktop update startup poller failed."); + assert.strictEqual(eventError.cause, cause); + assert.equal(eventError.event, "download-progress"); + assert.equal(eventError.message, "Failed to handle desktop update download-progress event."); + assert.strictEqual(reportedError.cause, cause); + assert.equal(reportedError.operation, "download"); + assert.equal(reportedError.message, "Desktop updater download operation reported an error."); + assert.strictEqual(unexpectedActionError.cause, cause); + assert.equal(unexpectedActionError.action, "install"); + assert.equal( + unexpectedActionError.message, + "Desktop update install action failed unexpectedly.", + ); + }); + it.effect("configures the updater and runs startup checks on the test clock", () => { const harness = makeHarness(); @@ -222,6 +278,178 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("keeps raw updater event failures out of update state", () => { + const harness = makeHarness(); + const cause = new Error( + "request failed for https://user:secret@example.com/update?token=secret", + ); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("error", cause); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "error"); + assert.equal(state.message, "Desktop updater background operation reported an error."); + assert.notInclude(state.message ?? "", "secret"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("logs bounded updater failure context without exposing the cause", () => { + const cause = new Error( + "request failed for https://user:secret@example.com/update?token=secret", + ); + const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ + channel: null, + cause, + }); + const harness = makeHarness({ checkForUpdates: Effect.fail(updaterError) }); + const loggedAnnotations: Array> = []; + const logger = Logger.make(({ fiber }) => { + const annotations = fiber.getRef(References.CurrentLogAnnotations); + if (annotations.errorTag === "ElectronUpdaterCheckForUpdatesError") { + loggedAnnotations.push(annotations); + } + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.check("manual"); + + const state = yield* updates.getState; + const loggedAnnotation = loggedAnnotations.at(-1); + assert.isDefined(loggedAnnotation); + assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError"); + assert.isNull(loggedAnnotation.channel); + assert.notProperty(loggedAnnotation, "error"); + assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret"); + assert.equal( + state.message, + "Electron updater failed to check for updates on channel default.", + ); + assert.notInclude(state.message ?? "", "secret"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + TestClock.layer(), + harness.layer, + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + + it.effect("recovers download state after an unexpected setup failure", () => { + let disableDifferentialCalls = 0; + const harness = makeHarness({ + setDisableDifferentialDownload: Effect.suspend(() => { + disableDifferentialCalls += 1; + return disableDifferentialCalls === 1 + ? Effect.void + : Effect.die(new Error("download setup failed")); + }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.download; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "available"); + assert.equal(failedState.errorContext, "download"); + assert.equal(failedState.message, "Desktop update download action failed unexpectedly."); + + const changedState = yield* updates.setChannel("nightly"); + assert.equal(changedState.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("restores download state and permits retry after interruption", () => + Effect.gen(function* () { + const actionStarted = yield* Deferred.make(); + let disableDifferentialCalls = 0; + const harness = makeHarness({ + setDisableDifferentialDownload: Effect.suspend(() => { + disableDifferentialCalls += 1; + if (disableDifferentialCalls === 1) { + return Effect.void; + } + if (disableDifferentialCalls === 2) { + return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never)); + } + return Effect.void; + }), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const downloadFiber = yield* updates.download.pipe(Effect.forkScoped); + yield* Deferred.await(actionStarted); + yield* Fiber.interrupt(downloadFiber); + + const interruptedState = yield* updates.getState; + assert.equal(interruptedState.status, "available"); + assert.isNull(interruptedState.message); + + const retry = yield* updates.download; + assert.isTrue(retry.accepted); + assert.isTrue(retry.completed); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + + it.effect("clears quitting state after an unexpected install setup failure", () => { + const harness = makeHarness({ + stopBackend: Effect.die(new Error("backend stop failed")), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "downloaded"); + assert.equal(failedState.errorContext, "install"); + assert.equal(failedState.message, "Desktop update install action failed unexpectedly."); + + const changedState = yield* updates.setChannel("nightly"); + assert.equal(changedState.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); @@ -284,6 +512,7 @@ describe("DesktopUpdates", () => { const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); assert.equal(error.action, "check"); + assert.equal(error.requestedChannel, "nightly"); } yield* Deferred.succeed(releaseCheck, undefined); @@ -292,4 +521,31 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), ); + + it.effect("preserves settings failure context when an update channel cannot be persisted", () => { + const diskFailure = new Error("disk exploded"); + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/settings.json", + cause: diskFailure, + }); + const harness = makeHarness({ setUpdateChannelError: settingsFailure }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const error = yield* updates.setChannel("nightly").pipe(Effect.flip); + + assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); + assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); + assert.equal(error.channel, "nightly"); + assert.strictEqual(error.cause, settingsFailure); + assert.strictEqual(error.cause.cause, diskFailure); + assert.equal(error.message, "Failed to persist the nightly desktop update channel."); + assert.notInclude(error.message, diskFailure.message); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index e9142c369e5..aecbdcfc3e8 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,9 +1,10 @@ -import type { - DesktopRuntimeInfo, - DesktopUpdateActionResult, - DesktopUpdateChannel, - DesktopUpdateCheckResult, - DesktopUpdateState, +import { + DesktopUpdateChannelSchema, + type DesktopRuntimeInfo, + type DesktopUpdateActionResult, + type DesktopUpdateChannel, + type DesktopUpdateCheckResult, + type DesktopUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -63,30 +64,82 @@ export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass< "DesktopUpdateActionInProgressError", { action: Schema.Literals(["check", "download", "install"]), + requestedChannel: DesktopUpdateChannelSchema, + }, +) { + override get message(): string { + return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`; + } +} + +export class DesktopUpdateChannelPersistenceError extends Schema.TaggedErrorClass()( + "DesktopUpdateChannelPersistenceError", + { + channel: DesktopUpdateChannelSchema, + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist the ${this.channel} desktop update channel.`; + } +} + +export class DesktopUpdatePollerError extends Schema.TaggedErrorClass()( + "DesktopUpdatePollerError", + { + poller: Schema.Literals(["startup", "poll"]), + cause: Schema.Defect(), }, ) { override get message(): string { - return `Cannot change update tracks while an update ${this.action} action is in progress.`; + return `Desktop update ${this.poller} poller failed.`; } } -export class DesktopUpdatePersistenceError extends Schema.TaggedErrorClass()( - "DesktopUpdatePersistenceError", +export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass()( + "DesktopUpdateEventHandlingError", { + event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]), 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}`; + return `Failed to handle desktop update ${this.event} event.`; + } +} + +export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass()( + "DesktopUpdaterReportedError", + { + operation: Schema.Literals(["check", "download", "install", "background"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop updater ${this.operation} operation reported an error.`; + } +} + +export class DesktopUpdateUnexpectedActionError extends Schema.TaggedErrorClass()( + "DesktopUpdateUnexpectedActionError", + { + action: Schema.Literals(["download", "install"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop update ${this.action} action failed unexpectedly.`; } } export type DesktopUpdateConfigureError = never; -export type DesktopUpdateSetChannelError = - | DesktopUpdateActionInProgressError - | DesktopUpdatePersistenceError; +export const DesktopUpdateSetChannelError = Schema.Union([ + DesktopUpdateActionInProgressError, + DesktopUpdateChannelPersistenceError, +]); +export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; +export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, @@ -308,16 +361,21 @@ export const make = Effect.gen(function* () { return yield* electronUpdater.checkForUpdates.pipe( Effect.as(true), - Effect.catch( - Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) { + Effect.catchTags({ + ElectronUpdaterCheckForUpdatesError: Effect.fn( + "desktop.updates.handleCheckForUpdatesFailure", + )(function* (error) { const failedAt = yield* currentIsoTimestamp; yield* updateState((current) => reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), ); - yield* logUpdaterError("failed to check for updates", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + }); return true; }), - ), + }), Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), ); }); @@ -342,19 +400,50 @@ export const make = Effect.gen(function* () { yield* electronUpdater.downloadUpdate; return { accepted: true, completed: true }; }).pipe( - Effect.catch( - Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { + Effect.catchTags({ + ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( + function* (error) { + yield* updateState((current) => + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + }); + return { accepted: true, completed: false }; + }, + ), + }), + Effect.onInterrupt(() => + updateState((current) => (current.status === "downloading" ? state : current)).pipe( + Effect.asVoid, + ), + ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause }); + return Effect.gen(function* () { yield* updateState((current) => reduceDesktopUpdateStateOnDownloadFailure(current, error.message), ); - yield* logUpdaterError("failed to download update", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); return { accepted: true, completed: false }; - }), - ), + }); + }), Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), ); }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + const resetInstallAction = Effect.all( + [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)], + { discard: true }, + ); + const installDownloadedUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); if ( @@ -377,14 +466,38 @@ export const make = Effect.gen(function* () { }); return { accepted: true, completed: false }; }).pipe( - Effect.catch( - Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { - yield* Ref.set(updateInstallInFlightRef, false); + Effect.catchTags({ + ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( + function* (error) { + yield* resetInstallAction; + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + isSilent: error.isSilent, + isForceRunAfter: error.isForceRunAfter, + }); + return { accepted: true, completed: false }; + }, + ), + }), + Effect.onInterrupt(() => resetInstallAction), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + yield* resetInstallAction; + const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, error.message), ); - yield* Ref.set(desktopState.quitting, false); - yield* logUpdaterError("failed to install update", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); return { accepted: true, completed: false }; }), ), @@ -394,17 +507,31 @@ export const make = Effect.gen(function* () { const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( Effect.andThen(checkForUpdates("startup")), - Effect.catchCause((cause) => - logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdatePollerError({ poller: "startup", cause }); + return logUpdaterError(error.message, { + errorTag: error._tag, + poller: error.poller, + }); + }), Effect.forkScoped, ); yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( Effect.andThen(checkForUpdates("poll")), Effect.forever, - Effect.catchCause((cause) => - logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdatePollerError({ poller: "poll", cause }); + return logUpdaterError(error.message, { + errorTag: error._tag, + poller: error.poller, + }); + }), Effect.forkScoped, ); }).pipe(Effect.withSpan("desktop.updates.startPollers")); @@ -435,11 +562,16 @@ export const make = Effect.gen(function* () { yield* logUpdaterInfo("update available", { version: info.version }); }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed update-available event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -452,14 +584,23 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( - error: unknown, + cause: unknown, ) { - const message = error instanceof Error ? error.message : String(error); + const activeAction = yield* activeUpdateAction; + const error = new DesktopUpdaterReportedError({ + operation: Option.getOrElse(activeAction, () => "background" as const), + cause, + }); if (yield* Ref.get(updateInstallInFlightRef)) { yield* Ref.set(updateInstallInFlightRef, false); yield* Ref.set(desktopState.quitting, false); - yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); - yield* logUpdaterError("updater error", { message }); + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); return; } @@ -469,7 +610,7 @@ export const make = Effect.gen(function* () { yield* updateState((current) => ({ ...current, status: "error", - message, + message: error.message, checkedAt, downloadPercent: null, errorContext, @@ -477,7 +618,10 @@ export const make = Effect.gen(function* () { })); } - yield* logUpdaterError("updater error", { message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); }); const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( @@ -499,11 +643,16 @@ export const make = Effect.gen(function* () { } }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed download-progress event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -518,11 +667,16 @@ export const make = Effect.gen(function* () { yield* logUpdaterInfo("update downloaded", { version: info.version }); }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed update-downloaded event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -598,7 +752,10 @@ export const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel: nextChannel }); const activeAction = yield* activeUpdateAction; if (Option.isSome(activeAction)) { - return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); + return yield* new DesktopUpdateActionInProgressError({ + action: activeAction.value, + requestedChannel: nextChannel, + }); } const state = yield* Ref.get(updateStateRef); @@ -608,7 +765,11 @@ export const make = Effect.gen(function* () { yield* desktopSettings .setUpdateChannel(nextChannel) - .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }), + ), + ); const enabled = yield* shouldEnableAutoUpdates; yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); From 4cd958445f60dcdbc5ad3997a35b1bd995e9d725 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:25:27 -0700 Subject: [PATCH 213/257] [codex] Migrate desktop app errors to Schema (#3449) Co-authored-by: codex --- apps/desktop/src/app/DesktopApp.ts | 24 ++++++++------- apps/desktop/src/app/DesktopAppErrors.test.ts | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/app/DesktopAppErrors.test.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index f498c3340e6..214fd383e04 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,8 +1,8 @@ import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as NetService from "@t3tools/shared/Net"; import * as Crypto from "effect/Crypto"; @@ -33,22 +33,24 @@ const makeDesktopRunId = Crypto.Crypto.pipe( Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), ); -class DesktopBackendPortUnavailableError extends Data.TaggedError( +export class DesktopBackendPortUnavailableError extends Schema.TaggedErrorClass()( "DesktopBackendPortUnavailableError", -)<{ - readonly startPort: number; - readonly maxPort: number; - readonly hosts: readonly string[]; -}> { - override get message() { + { + startPort: Schema.Int, + maxPort: Schema.Int, + hosts: Schema.Array(Schema.String), + }, +) { + override get message(): string { return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; } } -class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( +export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedErrorClass()( "DesktopDevelopmentBackendPortRequiredError", -)<{}> { - override get message() { + {}, +) { + override get message(): string { return "T3CODE_PORT is required in desktop development."; } } diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts new file mode 100644 index 00000000000..666c36d391d --- /dev/null +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -0,0 +1,30 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + DesktopBackendPortUnavailableError, + DesktopDevelopmentBackendPortRequiredError, +} from "./DesktopApp.ts"; + +describe("DesktopApp errors", () => { + it("preserves unavailable backend port context", () => { + const error = new DesktopBackendPortUnavailableError({ + startPort: 3_773, + maxPort: 65_535, + hosts: ["127.0.0.1", "0.0.0.0", "::"], + }); + + assert.equal(error.startPort, 3_773); + assert.equal(error.maxPort, 65_535); + assert.deepEqual(error.hosts, ["127.0.0.1", "0.0.0.0", "::"]); + assert.equal( + error.message, + "No desktop backend port is available on hosts 127.0.0.1, 0.0.0.0, :: between 3773 and 65535.", + ); + }); + + it("reports the required development port", () => { + const error = new DesktopDevelopmentBackendPortRequiredError(); + + assert.equal(error.message, "T3CODE_PORT is required in desktop development."); + }); +}); From 68c0dd3b7095cae054a9db5e48157e89f65c0664 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:35:02 -0700 Subject: [PATCH 214/257] [codex] Structure server CLI failures (#3450) Co-authored-by: codex --- apps/server/scripts/cli.ts | 43 ++++++++-------- apps/server/scripts/cliErrors.test.ts | 31 ++++++++++++ apps/server/scripts/cliErrors.ts | 70 +++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 apps/server/scripts/cliErrors.test.ts create mode 100644 apps/server/scripts/cliErrors.ts diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index a158eaa068d..00b6c4cfcce 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; @@ -20,6 +19,14 @@ import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import serverPackageJson from "../package.json" with { type: "json" }; +import { + ServerCliBuildAssetMissingError, + ServerCliCommandExitError, + ServerCliDevelopmentIconSourceMissingError, + ServerCliDevelopmentIconTargetMissingError, + ServerCliPublishIconSourceMissingError, + ServerCliPublishIconTargetMissingError, +} from "./cliErrors.ts"; interface PackageJson { name: string; @@ -47,11 +54,6 @@ const WorkspaceConfig = Schema.Struct({ type WorkspaceConfig = typeof WorkspaceConfig.Type; const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); -class CliError extends Data.TaggedError("CliError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), ); @@ -64,14 +66,17 @@ const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { return yield* decodeWorkspaceConfig(workspaceYaml); }); -const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Command) { +const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn(command); const exitCode = yield* child.exitCode; if (exitCode !== 0) { - return yield* new CliError({ - message: `Command exited with non-zero exit code (${exitCode})`, + return yield* new ServerCliCommandExitError({ + command: command.command, + args: command.args, + cwd: command.options.cwd, + exitCode, }); } }); @@ -95,14 +100,10 @@ const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(functio const backupPath = `${targetPath}.publish-bak`; if (!(yield* fs.exists(sourcePath))) { - return yield* new CliError({ - message: `Missing publish icon source: ${sourcePath}`, - }); + return yield* new ServerCliPublishIconSourceMissingError({ sourcePath }); } if (!(yield* fs.exists(targetPath))) { - return yield* new CliError({ - message: `Missing publish icon target: ${targetPath}. Run the build subcommand first.`, - }); + return yield* new ServerCliPublishIconTargetMissingError({ targetPath }); } yield* fs.copyFile(targetPath, backupPath); @@ -138,14 +139,10 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides") const targetPath = path.join(serverDir, override.targetRelativePath); if (!(yield* fs.exists(sourcePath))) { - return yield* new CliError({ - message: `Missing development icon source: ${sourcePath}`, - }); + return yield* new ServerCliDevelopmentIconSourceMissingError({ sourcePath }); } if (!(yield* fs.exists(targetPath))) { - return yield* new CliError({ - message: `Missing development icon target: ${targetPath}. Build web first.`, - }); + return yield* new ServerCliDevelopmentIconTargetMissingError({ targetPath }); } yield* fs.copyFile(sourcePath, targetPath); @@ -245,9 +242,7 @@ const publishCmd = Command.make( for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { - return yield* new CliError({ - message: `Missing build asset: ${abs}. Run the build subcommand first.`, - }); + return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); } } diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts new file mode 100644 index 00000000000..91754290db9 --- /dev/null +++ b/apps/server/scripts/cliErrors.test.ts @@ -0,0 +1,31 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts"; + +describe("server CLI errors", () => { + it("preserves failed command context without changing its message", () => { + const error = new ServerCliCommandExitError({ + command: "vp", + args: ["pm", "publish"], + cwd: "/repo", + exitCode: 17, + }); + + assert.equal(error._tag, "ServerCliCommandExitError"); + assert.equal(error.command, "vp"); + assert.deepEqual(error.args, ["pm", "publish"]); + assert.equal(error.cwd, "/repo"); + assert.equal(error.exitCode, 17); + assert.equal(error.message, "Command exited with non-zero exit code (17)"); + }); + + it("preserves a representative missing asset path", () => { + const error = new ServerCliBuildAssetMissingError({ assetPath: "/repo/server.mjs" }); + + assert.equal(error.assetPath, "/repo/server.mjs"); + assert.equal( + error.message, + "Missing build asset: /repo/server.mjs. Run the build subcommand first.", + ); + }); +}); diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts new file mode 100644 index 00000000000..d384c745f29 --- /dev/null +++ b/apps/server/scripts/cliErrors.ts @@ -0,0 +1,70 @@ +import * as Schema from "effect/Schema"; + +export class ServerCliCommandExitError extends Schema.TaggedErrorClass()( + "ServerCliCommandExitError", + { + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.optional(Schema.String), + exitCode: Schema.Int, + }, +) { + override get message(): string { + return `Command exited with non-zero exit code (${this.exitCode})`; + } +} + +export class ServerCliPublishIconSourceMissingError extends Schema.TaggedErrorClass()( + "ServerCliPublishIconSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing publish icon source: ${this.sourcePath}`; + } +} + +export class ServerCliPublishIconTargetMissingError extends Schema.TaggedErrorClass()( + "ServerCliPublishIconTargetMissingError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing publish icon target: ${this.targetPath}. Run the build subcommand first.`; + } +} + +export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedErrorClass()( + "ServerCliDevelopmentIconSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing development icon source: ${this.sourcePath}`; + } +} + +export class ServerCliDevelopmentIconTargetMissingError extends Schema.TaggedErrorClass()( + "ServerCliDevelopmentIconTargetMissingError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing development icon target: ${this.targetPath}. Build web first.`; + } +} + +export class ServerCliBuildAssetMissingError extends Schema.TaggedErrorClass()( + "ServerCliBuildAssetMissingError", + { + assetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`; + } +} From 0eba548282b96ee420ad0b1a3f5d9d4487376b90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:35:07 -0700 Subject: [PATCH 215/257] [codex] Model asset access failures with distinct errors (#3448) Co-authored-by: codex --- apps/server/src/assets/AssetAccess.test.ts | 5 +- apps/server/src/assets/AssetAccess.ts | 71 ++++---- apps/server/src/ws.ts | 19 +-- packages/contracts/src/assets.ts | 184 ++++++++++++++++++--- 4 files changed, 200 insertions(+), 79 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 7df2e3361c8..f790e71f5cd 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -88,7 +88,7 @@ describe("AssetAccess", () => { }).pipe(Effect.flip); expect(error.message).toBe("Workspace file path must be relative to the project root."); expect(error).toMatchObject({ - operation: "validate-workspace-path", + _tag: "AssetWorkspacePathValidationError", resource: { _tag: "workspace-file", threadId: "thread-1", @@ -130,7 +130,7 @@ describe("AssetAccess", () => { expect(error.message).toBe("Failed to inspect the workspace asset."); expect(error).toMatchObject({ - operation: "inspect-workspace-asset", + _tag: "AssetWorkspaceAssetInspectionError", resource: { _tag: "workspace-file", threadId: "thread-1", @@ -268,6 +268,7 @@ describe("AssetAccess", () => { ); expect(error.message).toBe("Failed to resolve project favicon."); + expect(error._tag).toBe("AssetProjectFaviconResolutionError"); expect(error.cause).toBe(resolutionCause); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index f7be262b41a..8d8ecbc2af3 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,5 +1,18 @@ import type { AssetResource } from "@t3tools/contracts"; -import { AssetAccessError } from "@t3tools/contracts"; +import { + AssetAttachmentNotFoundError, + AssetPreviewTypeValidationError, + AssetProjectFaviconInspectionError, + AssetProjectFaviconNotFoundError, + AssetProjectFaviconResolutionError, + AssetSigningKeyLoadError, + AssetWorkspaceAssetInspectionError, + AssetWorkspaceAssetNotFoundError, + AssetWorkspaceContextNotFoundError, + AssetWorkspacePathValidationError, + AssetWorkspaceResolutionError, + AssetWorkspaceRootNormalizationError, +} from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, @@ -165,19 +178,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { - return yield* new AssetAccessError({ - operation: "resolve-workspace-context", + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, - message: "Workspace context was not found.", }); } const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "normalize-workspace-root", + new AssetWorkspaceRootNormalizationError({ resource: input.resource, - message: "Failed to normalize the workspace root.", cause, }), ), @@ -190,19 +199,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i .pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "validate-workspace-path", + new AssetWorkspacePathValidationError({ resource: input.resource, - message: "Workspace file path must be relative to the project root.", cause, }), ), ); if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { - return yield* new AssetAccessError({ - operation: "validate-preview-type", + return yield* new AssetPreviewTypeValidationError({ resource: input.resource, - message: "Only browser documents and images can be previewed.", }); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ @@ -211,28 +216,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "inspect-workspace-asset", + new AssetWorkspaceAssetInspectionError({ resource: input.resource, - message: "Failed to inspect the workspace asset.", cause, }), ), ); if (!canonicalFile) { - return yield* new AssetAccessError({ - operation: "locate-workspace-asset", + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource, - message: "Workspace asset was not found.", }); } const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-workspace", + new AssetWorkspaceResolutionError({ resource: input.resource, - message: "Failed to resolve workspace.", cause, }), ), @@ -262,10 +261,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { - return yield* new AssetAccessError({ - operation: "locate-attachment", + return yield* new AssetAttachmentNotFoundError({ resource: input.resource, - message: "Attachment was not found.", }); } claims = { @@ -281,10 +278,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "normalize-workspace-root", + new AssetWorkspaceRootNormalizationError({ resource: input.resource, - message: "Failed to normalize the workspace root.", cause, }), ), @@ -293,10 +288,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-project-favicon", + new AssetProjectFaviconResolutionError({ resource: input.resource, - message: "Failed to resolve project favicon.", cause, }), ), @@ -307,19 +300,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "inspect-project-favicon", + new AssetProjectFaviconInspectionError({ resource: input.resource, - message: "Failed to inspect the project favicon.", cause, }), ), )) ) { - return yield* new AssetAccessError({ - operation: "locate-project-favicon", + return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, - message: "Project favicon was not found.", }); } claims = { @@ -328,10 +317,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-workspace", + new AssetWorkspaceResolutionError({ resource: input.resource, - message: "Failed to resolve workspace.", cause, }), ), @@ -348,10 +335,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "load-signing-key", + new AssetSigningKeyLoadError({ resource: input.resource, - message: "Failed to load the asset signing key.", cause, }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7ebc432038c..554a942d78a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -46,7 +46,8 @@ import { OrchestrationReplayEventsError, type FilesystemBrowseFailure, FilesystemBrowseError, - AssetAccessError, + AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -1413,19 +1414,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => .pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-workspace-context", + new AssetWorkspaceContextResolutionError({ resource: input.resource, - message: "Failed to resolve workspace context.", cause, }), ), ); if (Option.isNone(thread)) { - return yield* new AssetAccessError({ - operation: "resolve-workspace-context", + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, - message: "Workspace context was not found.", }); } const project = yield* projectionSnapshotQuery @@ -1433,19 +1430,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => .pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-workspace-context", + new AssetWorkspaceContextResolutionError({ resource: input.resource, - message: "Failed to resolve workspace context.", cause, }), ), ); if (Option.isNone(project)) { - return yield* new AssetAccessError({ - operation: "resolve-workspace-context", + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, - message: "Workspace context was not found.", }); } return yield* issueAssetUrl({ diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index fdfbe64246e..0dbe7d9fa25 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -29,28 +29,170 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; -export const AssetAccessOperation = Schema.Literals([ - "resolve-workspace-context", - "normalize-workspace-root", - "validate-workspace-path", - "validate-preview-type", - "inspect-workspace-asset", - "locate-workspace-asset", - "resolve-workspace", - "locate-attachment", - "resolve-project-favicon", - "inspect-project-favicon", - "locate-project-favicon", - "load-signing-key", -]); -export type AssetAccessOperation = typeof AssetAccessOperation.Type; +export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( + "AssetWorkspaceContextNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Workspace context was not found."; + } +} + +export class AssetWorkspaceContextResolutionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceContextResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve workspace context."; + } +} + +export class AssetWorkspaceRootNormalizationError extends Schema.TaggedErrorClass()( + "AssetWorkspaceRootNormalizationError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to normalize the workspace root."; + } +} + +export class AssetWorkspacePathValidationError extends Schema.TaggedErrorClass()( + "AssetWorkspacePathValidationError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Workspace file path must be relative to the project root."; + } +} + +export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass()( + "AssetPreviewTypeValidationError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Only browser documents and images can be previewed."; + } +} + +export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceAssetInspectionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to inspect the workspace asset."; + } +} + +export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass()( + "AssetWorkspaceAssetNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Workspace asset was not found."; + } +} + +export class AssetWorkspaceResolutionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve workspace."; + } +} -export class AssetAccessError extends Schema.TaggedErrorClass()( - "AssetAccessError", +export class AssetAttachmentNotFoundError extends Schema.TaggedErrorClass()( + "AssetAttachmentNotFoundError", { - operation: AssetAccessOperation, resource: AssetResource, - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), }, -) {} +) { + override get message(): string { + return "Attachment was not found."; + } +} + +export class AssetProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve project favicon."; + } +} + +export class AssetProjectFaviconInspectionError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconInspectionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to inspect the project favicon."; + } +} + +export class AssetProjectFaviconNotFoundError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Project favicon was not found."; + } +} + +export class AssetSigningKeyLoadError extends Schema.TaggedErrorClass()( + "AssetSigningKeyLoadError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load the asset signing key."; + } +} + +export const AssetAccessError = Schema.Union([ + AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, + AssetWorkspaceRootNormalizationError, + AssetWorkspacePathValidationError, + AssetPreviewTypeValidationError, + AssetWorkspaceAssetInspectionError, + AssetWorkspaceAssetNotFoundError, + AssetWorkspaceResolutionError, + AssetAttachmentNotFoundError, + AssetProjectFaviconResolutionError, + AssetProjectFaviconInspectionError, + AssetProjectFaviconNotFoundError, + AssetSigningKeyLoadError, +]); +export type AssetAccessError = typeof AssetAccessError.Type; From 0488e47d9c0d0ca42595341b15c1d187ea7107c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:07:22 -0700 Subject: [PATCH 216/257] [codex] Structure checkpoint diff failures (#3453) Co-authored-by: codex --- .../checkpointing/CheckpointDiffQuery.test.ts | 10 +- .../src/checkpointing/CheckpointDiffQuery.ts | 69 +++++++------ apps/server/src/checkpointing/Errors.test.ts | 39 ++++++++ apps/server/src/checkpointing/Errors.ts | 98 ++++++++++++++----- 4 files changed, 164 insertions(+), 52 deletions(-) create mode 100644 apps/server/src/checkpointing/Errors.test.ts diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8654fa0fec1..c1dbc833718 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -9,6 +9,7 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn import { checkpointRefForThreadTurn } from "./Utils.ts"; import * as CheckpointDiffQuery from "./CheckpointDiffQuery.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; +import { CheckpointThreadNotFoundError } from "./Errors.ts"; function makeThreadCheckpointContext(input: { readonly projectId: ProjectId; @@ -412,7 +413,14 @@ describe("CheckpointDiffQuery.layer", () => { }); }).pipe(Effect.provide(layer), Effect.flip); - expect(error.message).toContain("Thread 'thread-missing' not found."); + expect(error).toBeInstanceOf(CheckpointThreadNotFoundError); + expect(error).toMatchObject({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + }); + expect(error.message).toBe( + "Checkpoint invariant violation in CheckpointDiffQuery.getTurnDiff: Thread 'thread-missing' not found.", + ); }), ); }); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index d42c58dfff3..077506ff3a8 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -22,7 +22,13 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { CheckpointInvariantError, CheckpointUnavailableError } from "./Errors.ts"; +import { + CheckpointDiffResultInvalidError, + CheckpointRefUnavailableError, + CheckpointThreadNotFoundError, + CheckpointTurnRangeUnavailableError, + CheckpointWorkspacePathMissingError, +} from "./Errors.ts"; import type { CheckpointServiceError } from "./Errors.ts"; import { checkpointRefForThreadTurn } from "./Utils.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; @@ -92,9 +98,9 @@ export const make = Effect.gen(function* () { diff: "", }; if (!isTurnDiffResult(emptyDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed turn diff result does not satisfy contract schema.", + threadId: input.threadId, }); } return emptyDiff; @@ -104,9 +110,9 @@ export const make = Effect.gen(function* () { .getThreadCheckpointContext(input.threadId) .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); if (Option.isNone(threadContext)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointThreadNotFoundError({ operation, - detail: `Thread '${input.threadId}' not found.`, + threadId: input.threadId, }); } @@ -115,18 +121,19 @@ export const make = Effect.gen(function* () { 0, ); if (input.toTurnCount > maxTurnCount) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointTurnRangeUnavailableError({ + operation, threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, + requestedTurnCount: input.toTurnCount, + availableTurnCount: maxTurnCount, }); } const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; if (!workspaceCwd) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointWorkspacePathMissingError({ operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, + threadId: input.threadId, }); } @@ -137,10 +144,11 @@ export const make = Effect.gen(function* () { (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, )?.checkpointRef; if (!fromCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.fromTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, + checkpoint: "from", }); } @@ -148,10 +156,11 @@ export const make = Effect.gen(function* () { (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, )?.checkpointRef; if (!toCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.toTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, + checkpoint: "to", }); } @@ -167,9 +176,9 @@ export const make = Effect.gen(function* () { const turnDiff = buildTurnDiffResult(input, diff); if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed turn diff result does not satisfy contract schema.", + threadId: input.threadId, }); } @@ -200,9 +209,9 @@ export const make = Effect.gen(function* () { "", ); if (!isTurnDiffResult(emptyDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed full thread diff result does not satisfy contract schema.", + threadId: input.threadId, }); } return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; @@ -213,33 +222,35 @@ export const make = Effect.gen(function* () { .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); if (Option.isNone(threadContext)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointThreadNotFoundError({ operation, - detail: `Thread '${input.threadId}' not found.`, + threadId: input.threadId, }); } if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointTurnRangeUnavailableError({ + operation, threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, + requestedTurnCount: input.toTurnCount, + availableTurnCount: threadContext.value.latestCheckpointTurnCount, }); } const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; if (!workspaceCwd) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointWorkspacePathMissingError({ operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, + threadId: input.threadId, }); } if (!threadContext.value.toCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.toTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, + checkpoint: "to", }); } @@ -262,9 +273,9 @@ export const make = Effect.gen(function* () { diff, ); if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed full thread diff result does not satisfy contract schema.", + threadId: input.threadId, }); } diff --git a/apps/server/src/checkpointing/Errors.test.ts b/apps/server/src/checkpointing/Errors.test.ts new file mode 100644 index 00000000000..4c8b9c59cc3 --- /dev/null +++ b/apps/server/src/checkpointing/Errors.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; + +import { + CheckpointRefUnavailableError, + CheckpointTurnRangeUnavailableError, + CheckpointWorkspacePathMissingError, +} from "./Errors.ts"; + +const threadId = ThreadId.make("thread-1"); + +it("derives checkpoint messages from structured context", () => { + const range = new CheckpointTurnRangeUnavailableError({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + requestedTurnCount: 4, + availableTurnCount: 2, + }); + const checkpoint = new CheckpointRefUnavailableError({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + turnCount: 2, + checkpoint: "to", + }); + const workspace = new CheckpointWorkspacePathMissingError({ + operation: "CheckpointDiffQuery.getFullThreadDiff", + threadId, + }); + + expect(range.message).toBe( + "Checkpoint unavailable for thread thread-1 turn 4: Turn diff range exceeds current turn count: requested 4, current 2.", + ); + expect(checkpoint.message).toBe( + "Checkpoint unavailable for thread thread-1 turn 2: Checkpoint ref is unavailable for turn 2.", + ); + expect(workspace.message).toBe( + "Checkpoint invariant violation in CheckpointDiffQuery.getFullThreadDiff: Workspace path missing for thread 'thread-1' when computing full thread diff.", + ); +}); diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index 6feb58d584a..bdf409e2971 100644 --- a/apps/server/src/checkpointing/Errors.ts +++ b/apps/server/src/checkpointing/Errors.ts @@ -1,40 +1,94 @@ +import { NonNegativeInt, ThreadId, type VcsError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; + import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; -import type { VcsError } from "@t3tools/contracts"; -/** - * CheckpointUnavailableError - Expected checkpoint does not exist. - */ -export class CheckpointUnavailableError extends Schema.TaggedErrorClass()( - "CheckpointUnavailableError", +export const CheckpointDiffOperation = Schema.Literals([ + "CheckpointDiffQuery.getTurnDiff", + "CheckpointDiffQuery.getFullThreadDiff", +]); +export type CheckpointDiffOperation = typeof CheckpointDiffOperation.Type; + +/** The computed result does not satisfy the checkpoint RPC contract. */ +export class CheckpointDiffResultInvalidError extends Schema.TaggedErrorClass()( + "CheckpointDiffResultInvalidError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + const result = + this.operation === "CheckpointDiffQuery.getTurnDiff" ? "turn diff" : "full thread diff"; + return `Checkpoint invariant violation in ${this.operation}: Computed ${result} result does not satisfy contract schema.`; + } +} + +/** Projection state no longer contains the requested checkpoint thread. */ +export class CheckpointThreadNotFoundError extends Schema.TaggedErrorClass()( + "CheckpointThreadNotFoundError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + return `Checkpoint invariant violation in ${this.operation}: Thread '${this.threadId}' not found.`; + } +} + +/** The checkpoint thread has no workspace path from which to compute a diff. */ +export class CheckpointWorkspacePathMissingError extends Schema.TaggedErrorClass()( + "CheckpointWorkspacePathMissingError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + const diff = + this.operation === "CheckpointDiffQuery.getTurnDiff" ? "turn diff" : "full thread diff"; + return `Checkpoint invariant violation in ${this.operation}: Workspace path missing for thread '${this.threadId}' when computing ${diff}.`; + } +} + +/** The requested turn lies beyond the latest available checkpoint. */ +export class CheckpointTurnRangeUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointTurnRangeUnavailableError", { - threadId: Schema.String, - turnCount: Schema.Number, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: CheckpointDiffOperation, + threadId: ThreadId, + requestedTurnCount: NonNegativeInt, + availableTurnCount: NonNegativeInt, }, ) { override get message(): string { - return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.requestedTurnCount}: Turn diff range exceeds current turn count: requested ${this.requestedTurnCount}, current ${this.availableTurnCount}.`; } } -/** - * CheckpointInvariantError - Inconsistent provider/filesystem/catalog state. - */ -export class CheckpointInvariantError extends Schema.TaggedErrorClass()( - "CheckpointInvariantError", +/** Expected checkpoint metadata does not contain the requested Git ref. */ +export class CheckpointRefUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointRefUnavailableError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: CheckpointDiffOperation, + threadId: ThreadId, + turnCount: NonNegativeInt, + checkpoint: Schema.Literals(["from", "to"]), }, ) { override get message(): string { - return `Checkpoint invariant violation in ${this.operation}: ${this.detail}`; + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: Checkpoint ref is unavailable for turn ${this.turnCount}.`; } } -export type CheckpointStoreError = VcsError | CheckpointInvariantError | CheckpointUnavailableError; +export type CheckpointStoreError = VcsError; -export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError; +export type CheckpointServiceError = + | CheckpointStoreError + | ProjectionRepositoryError + | CheckpointDiffResultInvalidError + | CheckpointThreadNotFoundError + | CheckpointWorkspacePathMissingError + | CheckpointTurnRangeUnavailableError + | CheckpointRefUnavailableError; From 23ab75e84ab0680f6e71bf08dfb4b832d4bc4c87 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:19:07 -0700 Subject: [PATCH 217/257] [codex] Split project command failures (#3459) Co-authored-by: codex --- apps/server/src/cli/project.test.ts | 13 +- apps/server/src/cli/project.ts | 187 ++++++++++++++++++++-------- 2 files changed, 147 insertions(+), 53 deletions(-) diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 4d7e47ce541..5395592c889 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest"; import { EnvironmentInternalError } from "@t3tools/contracts"; -import { ProjectCommandError } from "./project.ts"; +import { + ProjectLiveServerDeclaredResponseError, + ProjectLiveServerRequestError, + projectCommandErrorFromLiveServerRequest, +} from "./project.ts"; it("maps declared server failures into structural project command errors", () => { const cause = new EnvironmentInternalError({ @@ -11,8 +15,9 @@ it("maps declared server failures into structural project command errors", () => traceId: "trace-123", }); - const error = ProjectCommandError.fromLiveServerRequest(cause); + const error = projectCommandErrorFromLiveServerRequest(cause); + assert.instanceOf(error, ProjectLiveServerDeclaredResponseError); assert.strictEqual(error.operation, "callLiveServer"); assert.strictEqual(error.code, "internal_error"); assert.strictEqual(error.traceId, "trace-123"); @@ -23,10 +28,10 @@ it("maps declared server failures into structural project command errors", () => it("preserves unexpected server failures without deriving the message from them", () => { const cause = new Error("credential abc123 was rejected"); - const error = ProjectCommandError.fromLiveServerRequest(cause); + const error = projectCommandErrorFromLiveServerRequest(cause); + assert.instanceOf(error, ProjectLiveServerRequestError); assert.strictEqual(error.operation, "callLiveServer"); - assert.strictEqual(error.detail, "Failed to call the running server."); assert.strictEqual(error.message, "Failed to call the running server."); assert.strictEqual(error.cause, cause); }); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 16f1f0e14d7..710d39c4c29 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -51,62 +51,148 @@ type ProjectCliDispatchCommand = Extract< >; const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); -const ProjectCommandOperation = Schema.Literals([ - "generateProjectCommandId", - "callLiveServer", - "validateProjectTitle", - "resolveProjectTarget", - "addProject", -]); -export class ProjectCommandError extends Schema.TaggedErrorClass()( - "ProjectCommandError", +export class ProjectCommandIdGenerationError extends Schema.TaggedErrorClass()( + "ProjectCommandIdGenerationError", + { + operation: Schema.Literal("generateProjectCommandId"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to generate a project command identifier."; + } +} + +export class ProjectLiveServerDeclaredResponseError extends Schema.TaggedErrorClass()( + "ProjectLiveServerDeclaredResponseError", + { + operation: Schema.Literal("callLiveServer"), + code: Schema.String, + traceId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server request failed (${this.code}, trace ${this.traceId}).`; + } +} + +export class ProjectLiveServerUndeclaredStatusError extends Schema.TaggedErrorClass()( + "ProjectLiveServerUndeclaredStatusError", + { + operation: Schema.Literal("callLiveServer"), + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server request failed with undeclared status ${this.status}.`; + } +} + +export class ProjectLiveServerRequestError extends Schema.TaggedErrorClass()( + "ProjectLiveServerRequestError", + { + operation: Schema.Literal("callLiveServer"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to call the running server."; + } +} + +export class ProjectTitleEmptyError extends Schema.TaggedErrorClass()( + "ProjectTitleEmptyError", + { + operation: Schema.Literal("validateProjectTitle"), + title: Schema.String, + }, +) { + override get message(): string { + return "Project title cannot be empty."; + } +} + +export class ProjectIdentifierEmptyError extends Schema.TaggedErrorClass()( + "ProjectIdentifierEmptyError", + { + operation: Schema.Literal("resolveProjectTarget"), + identifier: Schema.String, + }, +) { + override get message(): string { + return "Project identifier cannot be empty."; + } +} + +export class ProjectNotFoundError extends Schema.TaggedErrorClass()( + "ProjectNotFoundError", { - operation: ProjectCommandOperation, - detail: Schema.String, - code: Schema.optional(Schema.String), - traceId: Schema.optional(Schema.String), - status: Schema.optional(Schema.Number), + operation: Schema.Literal("resolveProjectTarget"), + identifier: Schema.String, + normalizedWorkspaceRoot: Schema.optional(Schema.String), + activeProjectCount: Schema.Number, cause: Schema.optional(Schema.Defect()), }, ) { - static fromLiveServerRequest(cause: unknown): ProjectCommandError { - if (isEnvironmentHttpCommonError(cause)) { - return new ProjectCommandError({ - operation: "callLiveServer", - detail: `Server request failed (${cause.code}, trace ${cause.traceId}).`, - code: cause.code, - traceId: cause.traceId, - cause, - }); - } - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - return new ProjectCommandError({ - operation: "callLiveServer", - detail: `Server request failed with undeclared status ${cause.response.status}.`, - status: cause.response.status, - cause, - }); - } - return new ProjectCommandError({ + override get message(): string { + return `No active project found for '${this.identifier}'.`; + } +} + +export class ProjectAlreadyExistsError extends Schema.TaggedErrorClass()( + "ProjectAlreadyExistsError", + { + operation: Schema.Literal("addProject"), + projectId: ProjectId, + workspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `An active project already exists for '${this.workspaceRoot}'.`; + } +} + +export const ProjectCommandError = Schema.Union([ + ProjectCommandIdGenerationError, + ProjectLiveServerDeclaredResponseError, + ProjectLiveServerUndeclaredStatusError, + ProjectLiveServerRequestError, + ProjectTitleEmptyError, + ProjectIdentifierEmptyError, + ProjectNotFoundError, + ProjectAlreadyExistsError, +]); +export type ProjectCommandError = typeof ProjectCommandError.Type; + +export function projectCommandErrorFromLiveServerRequest(cause: unknown): ProjectCommandError { + if (isEnvironmentHttpCommonError(cause)) { + return new ProjectLiveServerDeclaredResponseError({ operation: "callLiveServer", - detail: "Failed to call the running server.", + code: cause.code, + traceId: cause.traceId, cause, }); } - - override get message(): string { - return this.detail; + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + return new ProjectLiveServerUndeclaredStatusError({ + operation: "callLiveServer", + status: cause.response.status, + cause, + }); } + + return new ProjectLiveServerRequestError({ operation: "callLiveServer", cause }); } const projectCommandUuid = Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), Effect.mapError( (cause) => - new ProjectCommandError({ + new ProjectCommandIdGenerationError({ operation: "generateProjectCommandId", - detail: "Failed to generate a project command identifier.", cause, }), ), @@ -158,9 +244,9 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( if (trimmed.length > 0) { return trimmed; } - return yield* new ProjectCommandError({ + return yield* new ProjectTitleEmptyError({ operation: "validateProjectTitle", - detail: "Project title cannot be empty.", + title: explicitTitle, }); } @@ -175,9 +261,9 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( }) { const trimmedIdentifier = input.identifier.trim(); if (trimmedIdentifier.length === 0) { - return yield* new ProjectCommandError({ + return yield* new ProjectIdentifierEmptyError({ operation: "resolveProjectTarget", - detail: "Project identifier cannot be empty.", + identifier: input.identifier, }); } @@ -204,9 +290,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( const resolved = exactWorkspaceMatch; if (!resolved) { - return yield* new ProjectCommandError({ + return yield* new ProjectNotFoundError({ operation: "resolveProjectTarget", - detail: `No active project found for '${trimmedIdentifier}'.`, + identifier: trimmedIdentifier, + activeProjectCount: activeProjects.length, + ...(normalizedWorkspaceRoot === null ? {} : { normalizedWorkspaceRoot }), ...(normalizedWorkspaceRootResult._tag === "Failure" ? { cause: normalizedWorkspaceRootResult.failure } : {}), @@ -228,7 +316,7 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => }); }).pipe( withProjectCliLiveServerTimeout, - Effect.mapError(ProjectCommandError.fromLiveServerRequest), + Effect.mapError(projectCommandErrorFromLiveServerRequest), ); const dispatchLiveOrchestrationCommand = ( @@ -244,7 +332,7 @@ const dispatchLiveOrchestrationCommand = ( } as Parameters[0]); }).pipe( withProjectCliLiveServerTimeout, - Effect.mapError(ProjectCommandError.fromLiveServerRequest), + Effect.mapError(projectCommandErrorFromLiveServerRequest), ); const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { @@ -376,9 +464,10 @@ const projectAddCommand = Command.make("add", { (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, ); if (existingProject) { - return yield* new ProjectCommandError({ + return yield* new ProjectAlreadyExistsError({ operation: "addProject", - detail: `An active project already exists for '${workspaceRoot}'.`, + projectId: existingProject.id, + workspaceRoot, }); } From 8d4a8b4f3179a054b545b252a1b4c005e9da7875 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:19:12 -0700 Subject: [PATCH 218/257] [codex] Structure preview capability errors (#3454) Co-authored-by: codex --- .../src/mcp/McpInvocationContext.test.ts | 39 +++++++++++++++++++ apps/server/src/mcp/McpInvocationContext.ts | 14 +++++-- packages/contracts/src/previewAutomation.ts | 14 ++++++- 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/mcp/McpInvocationContext.test.ts diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts new file mode 100644 index 00000000000..39c68689047 --- /dev/null +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PreviewAutomationUnavailableError, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as McpInvocationContext from "./McpInvocationContext.ts"; + +it.effect("reports the scoped credential context when preview capability is unavailable", () => { + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(), + issuedAt: 1, + expiresAt: 2, + }; + + return Effect.gen(function* () { + const error = yield* McpInvocationContext.requireMcpCapability("preview").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.flip, + ); + + expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); + expect(error).toMatchObject({ + capability: "preview", + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }); + expect(error.message).toBe("MCP credential does not grant the preview capability."); + }); +}); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 0d3f84df42c..b13bf2d312e 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,5 +1,9 @@ -import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; +import { + type EnvironmentId, + PreviewAutomationUnavailableError, + type ProviderInstanceId, + type ThreadId, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -26,7 +30,11 @@ export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* const invocation = yield* McpInvocationContext; if (!invocation.capabilities.has(capability)) { return yield* new PreviewAutomationUnavailableError({ - message: `MCP credential does not grant the ${capability} capability.`, + capability, + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, }); } return invocation; diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index d6b9f59ae8d..118fb892737 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -453,8 +453,18 @@ export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass()( "PreviewAutomationUnavailableError", - { message: Schema.String }, -) {} + { + capability: Schema.Literal("preview"), + environmentId: EnvironmentId, + threadId: ThreadId, + providerSessionId: TrimmedNonEmptyString, + providerInstanceId: ProviderInstanceId, + }, +) { + override get message(): string { + return `MCP credential does not grant the ${this.capability} capability.`; + } +} const PreviewAutomationScopeErrorFields = { operation: PreviewAutomationOperation, From 6424197a19a28d5992be555633a6e37fd0a58573 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:30:19 -0700 Subject: [PATCH 219/257] [codex] Structure unroutable app-server messages (#3463) Co-authored-by: codex --- .../effect-codex-app-server/src/errors.ts | 33 ++++++++++++++++++- .../src/protocol.test.ts | 30 +++++++++++++++++ .../effect-codex-app-server/src/protocol.ts | 5 +-- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 2559bba618c..3826a099229 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -82,6 +82,11 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { return typeof payload; }; +const protocolMessageFields = ["id", "method", "params", "result", "error"] as const; + +export const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); +export type CodexAppServerProtocolMessageField = typeof CodexAppServerProtocolMessageField.Type; + export interface CodexAppServerRequestDiagnostics { readonly method?: string; readonly requestId?: string; @@ -157,7 +162,8 @@ export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass field in message); + const method = + "method" in message && typeof message.method === "string" ? message.method : undefined; + const requestId = + "id" in message && (typeof message.id === "string" || typeof message.id === "number") + ? String(message.id) + : undefined; + return new CodexAppServerProtocolParseError({ + operation: "route-wire-message", + ...diagnostics, + presentFields, + ...(method === undefined ? {} : { method }), + ...(requestId === undefined ? {} : { requestId }), + }); + } } export class CodexAppServerTransportError extends Schema.TaggedErrorClass()( diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index f387ca382be..0ed81b3b9e6 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -311,6 +311,36 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("describes unroutable messages with safe structural diagnostics", () => + Effect.gen(function* () { + const secret = "codex-unroutable-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: true, method: "thread/start", params: { token: secret } }), + ); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, CodexError.CodexAppServerProtocolParseError); + assert.deepInclude(error, { + operation: "route-wire-message", + method: "thread/start", + payloadKind: "object", + presentFields: ["id", "method", "params"], + }); + assert.isUndefined(error.requestId); + assert.notProperty(error, "detail"); + assert.notProperty(error, "cause"); + assert.notInclude(error.message, secret); + }), + ); + it.effect("classifies an input stream ending without inventing a cause", () => Effect.gen(function* () { const { stdio, input } = yield* makeInMemoryStdio(); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index fbf173cbc5e..825c59b9b2c 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -310,10 +310,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa return handleResponse(message); } return Effect.fail( - new CodexError.CodexAppServerProtocolParseError({ - detail: "Received protocol message in an unknown shape", - operation: "route-wire-message", - }), + CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message), ); }; From 1a59277dc84105e2131c2bc828deb51c0b12d7f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:41:25 -0700 Subject: [PATCH 220/257] [codex] Structure GitLab CLI failures (#3458) Co-authored-by: codex --- .../src/sourceControl/GitLabCli.test.ts | 31 +- apps/server/src/sourceControl/GitLabCli.ts | 389 ++++++++++++------ .../GitLabSourceControlProvider.test.ts | 3 +- 3 files changed, 297 insertions(+), 126 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 792e3a82b13..87621e5c8bc 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -315,10 +315,11 @@ layer("GitLabCli.layer", (it) => { Effect.gen(function* () { const cause = new VcsProcessExitError({ operation: "GitLabCli.execute", - command: "glab mr view 4888", + command: "glab", cwd: "/repo", exitCode: 1, detail: "GET 404 merge request not found", + failureKind: "not-found", }); mockedRun.mockReturnValueOnce(Effect.fail(cause)); @@ -330,11 +331,37 @@ layer("GitLabCli.layer", (it) => { }); }).pipe(Effect.flip); - assert.equal(error.message.includes("Merge request not found"), true); + assert.equal(error.message.includes("Merge request 4888 was not found"), true); + assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); assert.strictEqual(error.command, "glab"); assert.strictEqual(error.cwd, "/repo"); assert.strictEqual(error.cause, cause); assert.equal(error.message.includes(cause.detail), false); }), ); + + it.effect("keeps non-merge-request not-found failures generic", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 project not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "missing/project", + }); + }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitLabCliCommandError"); + assert.strictEqual(error.cause, cause); + }), + ); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index b34e72ffc95..3e3bbe742c1 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.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 * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -21,13 +22,56 @@ import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { - operation: Schema.String, - command: Schema.String, +const gitLabCliExecutionErrorContext = { + operation: Schema.Literal("execute"), + command: Schema.Literal("glab"), cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { + cause: Schema.Defect(), +}; + +const gitLabCliDecodeErrorContext = { + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +export class GitLabCliUnavailableError extends Schema.TaggedErrorClass()( + "GitLabCliUnavailableError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI (`glab`) is required but not available on PATH."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitLabCliAuthenticationError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestNotFoundError", + { + ...gitLabCliExecutionErrorContext, + reference: Schema.String, + }, +) { + get detail(): string { + return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; + } + override get message(): string { return `GitLab CLI failed in ${this.operation}: ${this.detail}`; } @@ -37,52 +81,145 @@ export class GitLabCliError extends Schema.TaggedErrorClass()("G readonly operation: "execute"; readonly command: "glab"; readonly cwd: string; + readonly reference: string; }, - error: VcsError | unknown, + error: VcsError, ): GitLabCliError { - const lower = errorText(error).toLowerCase(); - - if (lower.includes("command not found: glab") || isVcsProcessSpawnError(error)) { - return new GitLabCliError({ - ...context, - detail: "GitLab CLI (`glab`) is required but not available on PATH.", - cause: error, - }); + if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { + return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); } - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("glab auth login") || - lower.includes("token") - ) { - return new GitLabCliError({ - ...context, - detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - cause: error, - }); - } + return GitLabCliCommandError.fromVcsError( + { + operation: context.operation, + command: context.command, + cwd: context.cwd, + }, + error, + ); + } +} - if ( - lower.includes("merge request not found") || - lower.includes("not found") || - lower.includes("404") - ) { - return new GitLabCliError({ - ...context, - detail: "Merge request not found. Check the MR number or URL and try again.", - cause: error, - }); - } +export class GitLabCliCommandError extends Schema.TaggedErrorClass()( + "GitLabCliCommandError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI command failed."; + } - return new GitLabCliError({ - ...context, - detail: "GitLab CLI command failed.", - cause: error, + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + }, + error: VcsError, + ): GitLabCliError { + return Match.valueTags(error, { + VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), + VcsProcessExitError: (cause) => { + switch (cause.failureKind) { + case "authentication": + return new GitLabCliAuthenticationError({ ...context, cause }); + case "not-found": + case "command-failed": + case undefined: + return new GitLabCliCommandError({ ...context, cause }); + } + }, + VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); } } +export class GitLabMergeRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestListDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("listMergeRequests"), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid MR list JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestDecodeError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("getMergeRequest"), + reference: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid merge request JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitLabRepositoryDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), + repository: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid repository JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabNamespaceDecodeError extends Schema.TaggedErrorClass()( + "GitLabNamespaceDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("createRepository"), + namespacePath: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid namespace JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export const GitLabCliError = Schema.Union([ + GitLabCliUnavailableError, + GitLabCliAuthenticationError, + GitLabMergeRequestNotFoundError, + GitLabCliCommandError, + GitLabMergeRequestListDecodeError, + GitLabMergeRequestDecodeError, + GitLabRepositoryDecodeError, + GitLabNamespaceDecodeError, +]); +export type GitLabCliError = typeof GitLabCliError.Type; +export const isGitLabCliError = Schema.is(GitLabCliError); + export interface GitLabMergeRequestSummary { readonly number: number; readonly title: string; @@ -102,17 +239,6 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); - } - - return String(error); -} - export class GitLabCli extends Context.Service< GitLabCli, { @@ -168,15 +294,6 @@ export class GitLabCli extends Context.Service< } >()("t3/sourceControl/GitLabCli") {} -function isVcsProcessSpawnError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "VcsProcessSpawnError" - ); -} - const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ path_with_namespace: TrimmedNonEmptyString, web_url: TrimmedNonEmptyString, @@ -192,6 +309,14 @@ const RawGitLabNamespaceSchema = Schema.Struct({ id: Schema.Number, }); +const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), +); +const decodeGitLabDefaultBranch = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabDefaultBranchSchema), +); +const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitLabRepositoryCloneUrls { @@ -202,27 +327,6 @@ function normalizeRepositoryCloneUrls( }; } -function decodeGitLabJson( - raw: string, - schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, - cwd: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitLabCliError({ - operation, - command: "glab", - cwd, - detail: invalidDetail, - cause: error, - }), - ), - ); -} - function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { switch (state) { case "open": @@ -283,7 +387,10 @@ function parseRepositoryPath(repository: string): { export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitLabCli["Service"]["execute"] = (input) => + const run = ( + input: Parameters[0], + mapError: (error: VcsError) => GitLabCliError, + ) => process .run({ operation: "GitLabCli.execute", @@ -292,14 +399,32 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe( - Effect.mapError((error) => - GitLabCliError.fromVcsError( - { operation: "execute", command: "glab", cwd: input.cwd }, - error, - ), - ), - ); + .pipe(Effect.mapError(mapError)); + + const execute: GitLabCli["Service"]["execute"] = (input) => + run(input, (error) => + GitLabCliCommandError.fromVcsError( + { operation: "execute", command: "glab", cwd: input.cwd }, + error, + ), + ); + + const executeMergeRequest = (input: { + readonly cwd: string; + readonly reference: string; + readonly args: ReadonlyArray; + }) => + run(input, (error) => + GitLabMergeRequestNotFoundError.fromVcsError( + { + operation: "execute", + command: "glab", + cwd: input.cwd, + reference: input.reference, + }, + error, + ), + ); return GitLabCli.of({ execute, @@ -326,11 +451,10 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestListDecodeError({ operation: "listMergeRequests", command: "glab", cwd: input.cwd, - detail: "GitLab CLI returned invalid MR list JSON.", cause: decoded.failure, }), ); @@ -342,8 +466,9 @@ export const make = Effect.gen(function* () { ), ), getMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "view", input.reference, "--output", "json"], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -352,11 +477,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestDecodeError({ operation: "getMergeRequest", command: "glab", cwd: input.cwd, - detail: "GitLab CLI returned invalid merge request JSON.", + reference: input.reference, cause: decoded.failure, }), ); @@ -374,12 +499,17 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getRepositoryCloneUrls", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -393,12 +523,17 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabNamespaceSchema, - "createRepository", - "GitLab CLI returned invalid namespace JSON.", - input.cwd, + decodeGitLabNamespace(raw).pipe( + Effect.mapError( + (cause) => + new GitLabNamespaceDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + namespacePath, + cause, + }), + ), ), ), Effect.map((namespace) => namespace.id), @@ -428,12 +563,17 @@ export const make = Effect.gen(function* () { ), Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "createRepository", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -467,19 +607,24 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabDefaultBranchSchema, - "getDefaultBranch", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabDefaultBranch(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getDefaultBranch", + command: "glab", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map((value) => value.default_branch ?? null), ), checkoutMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "checkout", input.reference], }).pipe(Effect.asVoid), }); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 6ab3f23b150..0d06e066521 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -54,11 +54,10 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = it.effect("adds repository context while retaining GitLab CLI causes", () => Effect.gen(function* () { - const cause = new GitLabCli.GitLabCliError({ + const cause = new GitLabCli.GitLabCliCommandError({ operation: "execute", command: "glab", cwd: "/repo", - detail: "GitLab CLI command failed.", cause: new Error("raw upstream detail that should remain in the cause"), }); const provider = yield* makeProvider({ From a3dadc04bf38b49bc8476a8e6c59bda7dc81d2e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:47:12 -0700 Subject: [PATCH 221/257] [codex] Structure Bitbucket API errors (#3457) Co-authored-by: codex --- .../src/sourceControl/BitbucketApi.test.ts | 62 ++++- apps/server/src/sourceControl/BitbucketApi.ts | 217 ++++++++++++++---- .../BitbucketSourceControlProvider.test.ts | 9 +- 3 files changed, 239 insertions(+), 49 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index e4a7649e74a..5a9759ace0b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -533,8 +533,8 @@ it.effect("preserves the HTTP client failure without deriving the domain message }), ); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); 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.", @@ -544,6 +544,61 @@ it.effect("preserves the HTTP client failure without deriving the domain message }).pipe(Effect.provide(layer)); }); +it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { + const responseBody = '{"error":{"message":"credential=secret-value"}}'; + const { layer } = makeLayer({ + response: () => new Response(responseBody, { status: 403 }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .checkoutPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 403); + assert.strictEqual(error.responseBodyLength, responseBody.length); + assert.notProperty(error, "responseBody"); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.", + ); + assert.notInclude(error.message, "secret-value"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { + const cause = new Error("response stream failed"); + const { layer } = makeLayer({ + response: () => + new Response( + new ReadableStream({ + start: (controller) => controller.error(cause), + }), + { status: 502 }, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .getPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 502); + assert.instanceOf(error.cause, HttpClientError.HttpClientError); + assert.strictEqual(error.cause.cause, cause); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", + ); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => @@ -630,8 +685,9 @@ it.effect("preserves Git checkout failures without deriving the domain message f }), ); - assert.strictEqual(error.operation, "checkoutPullRequest"); - assert.strictEqual(error.detail, "Failed to check out the Bitbucket pull request."); + assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.reference, "42"); assert.strictEqual( error.message, "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 9a678ab44dc..f7d7f6671a4 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, @@ -36,20 +37,156 @@ const BitbucketApiEnvConfig = Config.all({ apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), }); -export class BitbucketApiError extends Schema.TaggedErrorClass()( - "BitbucketApiError", +const BitbucketApiOperation = Schema.Literals([ + "resolveRepository", + "getRepository", + "getBranchingModel", + "getPullRequest", + "listPullRequests", + "createRepository", + "createPullRequest", + "probeAuth", + "checkoutPullRequest", +]); +type BitbucketApiOperation = typeof BitbucketApiOperation.Type; + +export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryLocatorError", { - operation: Schema.String, - detail: Schema.String, - status: Schema.optional(Schema.Number), - cause: Schema.optional(Schema.Defect()), + repository: Schema.String, }, ) { override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + return "Bitbucket API failed in createRepository: Bitbucket repositories must be specified as workspace/repository."; } } -const isBitbucketApiError = Schema.is(BitbucketApiError); + +export class BitbucketRequestError extends Schema.TaggedErrorClass()( + "BitbucketRequestError", + { + operation: BitbucketApiOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Failed to send the Bitbucket request.`; + } +} + +export class BitbucketResponseError extends Schema.TaggedErrorClass()( + "BitbucketResponseError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + responseBodyLength: NonNegativeInt, + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; + } +} + +export class BitbucketResponseBodyReadError extends Schema.TaggedErrorClass()( + "BitbucketResponseBodyReadError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; + } +} + +export class BitbucketResponseDecodeError extends Schema.TaggedErrorClass()( + "BitbucketResponseDecodeError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned invalid JSON for the requested resource.`; + } +} + +export class BitbucketRepositoryVcsResolveError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryVcsResolveError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: Failed to resolve VCS repository for ${this.cwd}.`; + } +} + +export class BitbucketRepositoryRemotesListError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryRemotesListError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: Failed to list remotes for ${this.cwd}.`; + } +} + +export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryRemoteNotFoundError", + { + cwd: Schema.String, + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: No Bitbucket repository remote was detected for ${this.cwd}.`; + } +} + +export class BitbucketPullRequestBodyReadError extends Schema.TaggedErrorClass()( + "BitbucketPullRequestBodyReadError", + { + cwd: Schema.String, + bodyFile: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in createPullRequest: Failed to read pull request body file ${this.bodyFile}.`; + } +} + +export class BitbucketCheckoutError extends Schema.TaggedErrorClass()( + "BitbucketCheckoutError", + { + cwd: Schema.String, + reference: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request."; + } +} + +export const BitbucketApiError = Schema.Union([ + BitbucketRepositoryLocatorError, + BitbucketRequestError, + BitbucketResponseError, + BitbucketResponseBodyReadError, + BitbucketResponseDecodeError, + BitbucketRepositoryVcsResolveError, + BitbucketRepositoryRemotesListError, + BitbucketRepositoryRemoteNotFoundError, + BitbucketPullRequestBodyReadError, + BitbucketCheckoutError, +]); +export type BitbucketApiError = typeof BitbucketApiError.Type; +export const isBitbucketApiError = Schema.is(BitbucketApiError); const RawBitbucketRepositorySchema = Schema.Struct({ full_name: TrimmedNonEmptyString, @@ -209,16 +346,14 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator } function requireRepositoryLocator( - operation: string, repository: string, ): Effect.Effect { const locator = parseBitbucketRepositorySlug(repository); return locator ? Effect.succeed(locator) : Effect.fail( - new BitbucketApiError({ - operation, - detail: "Bitbucket repositories must be specified as workspace/repository.", + new BitbucketRepositoryLocatorError({ + repository, }), ); } @@ -339,20 +474,24 @@ function authFromConfig( } function responseError( - operation: string, + operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { return response.text.pipe( - Effect.orElseSucceed(() => ""), + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation, + status: response.status, + cause, + }), + ), Effect.flatMap((body) => Effect.fail( - new BitbucketApiError({ + new BitbucketResponseError({ operation, status: response.status, - detail: - body.trim().length > 0 - ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` - : `Bitbucket returned HTTP ${response.status}.`, + responseBodyLength: body.length, }), ), ), @@ -379,7 +518,7 @@ export const make = Effect.gen(function* () { }; const decodeResponse = ( - operation: string, + operation: BitbucketApiOperation, schema: S, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect => @@ -388,9 +527,9 @@ export const make = Effect.gen(function* () { HttpClientResponse.schemaBodyJson(schema)(success).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ + new BitbucketResponseDecodeError({ operation, - detail: "Bitbucket returned invalid JSON for the requested resource.", + status: success.status, cause, }), ), @@ -399,16 +538,15 @@ export const make = Effect.gen(function* () { })(response); const executeJson = ( - operation: string, + operation: BitbucketApiOperation, request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ + new BitbucketRequestError({ operation, - detail: "Failed to send the Bitbucket request.", cause, }), ), @@ -433,9 +571,8 @@ export const make = Effect.gen(function* () { const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to resolve VCS repository for ${input.cwd}.`, + new BitbucketRepositoryVcsResolveError({ + cwd: input.cwd, cause, }), ), @@ -443,9 +580,8 @@ export const make = Effect.gen(function* () { const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to list remotes for ${input.cwd}.`, + new BitbucketRepositoryRemotesListError({ + cwd: input.cwd, cause, }), ), @@ -457,9 +593,8 @@ export const make = Effect.gen(function* () { if (parsed) return parsed; } - return yield* new BitbucketApiError({ - operation: "resolveRepository", - detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, + return yield* new BitbucketRepositoryRemoteNotFoundError({ + cwd: input.cwd, }); }); @@ -600,7 +735,7 @@ export const make = Effect.gen(function* () { getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createRepository: (input) => - requireRepositoryLocator("createRepository", input.repository).pipe( + requireRepositoryLocator(input.repository).pipe( Effect.flatMap((repository) => executeJson( "createRepository", @@ -625,9 +760,9 @@ export const make = Effect.gen(function* () { const description = yield* fileSystem.readFileString(input.bodyFile).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "createPullRequest", - detail: `Failed to read pull request body file ${input.bodyFile}.`, + new BitbucketPullRequestBodyReadError({ + cwd: input.cwd, + bodyFile: input.bodyFile, cause, }), ), @@ -743,9 +878,9 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => isBitbucketApiError(cause) ? cause - : new BitbucketApiError({ - operation: "checkoutPullRequest", - detail: "Failed to check out the Bitbucket pull request.", + : new BitbucketCheckoutError({ + cwd: input.cwd, + reference: input.reference, cause, }), ), diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 75ac877cd43..eeb4c8fbdd2 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -53,11 +53,10 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( it.effect("adds repository context while retaining Bitbucket API causes", () => Effect.gen(function* () { - const cause = new BitbucketApi.BitbucketApiError({ + const upstreamCause = new Error("raw upstream failure"); + const cause = new BitbucketApi.BitbucketRequestError({ operation: "getRepository", - detail: "upstream detail that should remain in the cause", - status: 503, - cause: new Error("raw upstream failure"), + cause: upstreamCause, }); const provider = yield* makeProvider({ getRepositoryCloneUrls: () => Effect.fail(cause), @@ -86,7 +85,7 @@ it.effect("adds repository context while retaining Bitbucket API causes", () => }, ); assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.detail), false); + assert.equal(error.message.includes(upstreamCause.message), false); }), ); From 2fbb0565d7976cbb338f5593a566ff53c6165419 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:08 -0700 Subject: [PATCH 222/257] [codex] Structure workspace search cleanup failures (#3465) Co-authored-by: codex --- apps/server/src/workspace/WorkspaceEntries.ts | 27 ++++++++++------ .../workspace/WorkspaceSearchIndex.test.ts | 31 +++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 17 +++++++++- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 81fb735ea2e..7501cbe0eab 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -151,20 +151,29 @@ export const make = Effect.gen(function* () { if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { return; } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + cause, + }); + yield* workspaceSearchIndexes.invalidate(normalizedCwd); + }); yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; yield* searchIndex.refresh(); }).pipe( Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.catch((cause) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - cause, - }); - yield* workspaceSearchIndexes.invalidate(normalizedCwd); - }), - ), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), ); }, ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 41ea90b9735..9b7ed4e2453 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,6 +1,8 @@ import { FileFinder } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import { vi } from "vite-plus/test"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -49,6 +51,35 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain }), ); +it.effect("preserves FileFinder destroy failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("native destroy failed"); + const finder = { + destroy: vi.fn(() => { + throw cause; + }), + isScanning: vi.fn(() => false), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const exit = yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")).pipe( + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(WorkspaceSearchIndex.WorkspaceSearchIndexDestroyFailed); + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexDestroyFailed", + cwd: "/workspace/project", + cause, + }); + } + }), +); + it.effect("preserves search and refresh failures with operation context", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 2b043e05c0e..db4d46851e7 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -71,6 +71,18 @@ export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexDestroyFailed", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to destroy the workspace search index for '${this.cwd}'.`; + } +} + export type WorkspaceSearchIndexError = | WorkspaceSearchIndexCreateFailed | WorkspaceSearchIndexScanTimedOut @@ -201,7 +213,10 @@ const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unkn export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => - Effect.sync(() => finder.destroy()), + Effect.try({ + try: () => finder.destroy(), + catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), + }).pipe(Effect.orDie), ); yield* waitForScan( cwd, From cdd03591efb09baf06299e3764ddbd01c313a936 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:13 -0700 Subject: [PATCH 223/257] [codex] Structure release metadata failures (#3467) Co-authored-by: codex --- scripts/resolve-nightly-release.test.ts | 51 +++++++++ scripts/resolve-nightly-release.ts | 38 ++++++- scripts/resolve-previous-release-tag.test.ts | 93 +++++++++++++++- scripts/resolve-previous-release-tag.ts | 110 +++++++++++++++++-- 4 files changed, 278 insertions(+), 14 deletions(-) diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index ecc94c57f59..18d6a2a2ed7 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -1,7 +1,12 @@ +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 Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { + readDesktopBaseVersion, resolveNightlyBaseVersion, resolveNightlyReleaseMetadata, resolveNightlyTargetVersion, @@ -43,3 +48,49 @@ it("derives nightly metadata including the short commit sha in the release name" }, ); }); + +it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => { + it.effect("preserves desktop package read context and its platform cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "resolve-nightly-release-read-", + }); + const packageJsonPath = path.join(rootDir, "apps/desktop/package.json"); + + const error = yield* readDesktopBaseVersion(rootDir).pipe(Effect.flip); + + if (error._tag !== "NightlyReleaseDesktopPackageError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "read"); + assert.equal(error.packageJsonPath, packageJsonPath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.notInclude(error.message, String((error.cause as Error).message)); + }), + ); + + it.effect("preserves desktop package decode context and its schema cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "resolve-nightly-release-decode-", + }); + const packageJsonPath = path.join(rootDir, "apps/desktop/package.json"); + yield* fs.makeDirectory(path.dirname(packageJsonPath), { recursive: true }); + yield* fs.writeFileString(packageJsonPath, "{"); + + const error = yield* readDesktopBaseVersion(rootDir).pipe(Effect.flip); + + if (error._tag !== "NightlyReleaseDesktopPackageError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "decode"); + assert.equal(error.packageJsonPath, packageJsonPath); + assert.ok(error.cause !== undefined); + assert.notInclude(error.message, String((error.cause as Error).message)); + }), + ); +}); diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index ae6bc323c67..adad8c6f4f8 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -40,6 +40,19 @@ export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass()( + "NightlyReleaseDesktopPackageError", + { + operation: Schema.Literals(["read", "decode"]), + packageJsonPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} desktop package metadata at ${this.packageJsonPath}.`; + } +} + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -77,16 +90,33 @@ export const resolveNightlyReleaseMetadata = ( }; }; -const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( +export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( rootDir: string | undefined, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot; const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json"); - const packageJson = yield* fs - .readFileString(packageJsonPath) - .pipe(Effect.flatMap(decodeDesktopPackageJson)); + const packageJsonSource = yield* fs.readFileString(packageJsonPath).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseDesktopPackageError({ + operation: "read", + packageJsonPath, + cause, + }), + ), + ); + const packageJson = yield* decodeDesktopPackageJson(packageJsonSource).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseDesktopPackageError({ + operation: "decode", + packageJsonPath, + cause, + }), + ), + ); return yield* resolveNightlyTargetVersion(packageJson.version); }); diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts index ecf564c005e..a9c4832c26a 100644 --- a/scripts/resolve-previous-release-tag.test.ts +++ b/scripts/resolve-previous-release-tag.test.ts @@ -1,7 +1,33 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; +import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; + +const encoder = new TextEncoder(); + +function mockHandle(options: { + readonly exitCode: number; + readonly stdout?: string; + readonly stderr?: string; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(options.stdout ?? "")), + stderr: Stream.make(encoder.encode(options.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} it.effect("selects the latest earlier stable tag and ignores nightlies", () => Effect.gen(function* () { @@ -37,3 +63,68 @@ it.effect("reports the invalid tag with its release channel", () => assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'."); }), ); + +it.effect("preserves git tag spawn context and the exact platform cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "git was not found", + }); + + return Effect.gen(function* () { + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.executable, "git"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.notProperty(error, "args"); + assert.notInclude(error.message, cause.message); + }); +}); + +it.effect("reports git tag non-zero exits without manufacturing a cause", () => + Effect.gen(function* () { + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + exitCode: 17, + stdout: "v1.2.3\n", + stderr: "fatal: repository unavailable\n", + }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessExitError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.executable, "git"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo"); + assert.equal(error.exitCode, 17); + assert.equal(error.stdoutLength, 7); + assert.equal(error.stderrLength, 30); + assert.notProperty(error, "cause"); + assert.notProperty(error, "stdout"); + assert.notProperty(error, "stderr"); + }), +); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 8b1f1fc9648..83b6e65d1e1 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -2,7 +2,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Array from "effect/Array"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -27,6 +26,39 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( + "ReleaseTagListProcessError", + { + ...releaseTagListProcessContext, + operation: Schema.Literals(["spawn", "communicate", "wait-for-exit"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to list release tags during process operation "${this.operation}".`; + } +} + +export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass()( + "ReleaseTagListProcessExitError", + { + ...releaseTagListProcessContext, + exitCode: Schema.Number, + stdoutLength: Schema.Number, + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `Release tag listing exited with code ${this.exitCode}.`; + } +} + interface StableVersion { readonly major: number; readonly minor: number; @@ -172,20 +204,80 @@ export const resolvePreviousReleaseTag = ( return candidates[0]?.tag; }); -const listGitTags = Effect.fn("listGitTags")(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"])); - const tags = yield* child.stdout.pipe( +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), - Effect.map(String.split(/\r?\n/)), - Effect.map(Array.map(String.trim)), - Effect.map(Array.filter(String.isNonEmpty)), ); - return tags; + +export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd()) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const args = ["tag", "--list"] as const; + const context = { + executable: "git", + argumentCount: args.length, + cwd, + } as const; + const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd })).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "spawn", + cause, + }), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "communicate", + cause, + }), + ), + ), + collectStreamAsString(child.stderr).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "communicate", + cause, + }), + ), + ), + child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "wait-for-exit", + cause, + }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + return yield* new ReleaseTagListProcessExitError({ + ...context, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + + return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty); }); const writeOutput = Effect.fn("writeOutput")(function* ( From 6fc79737c3854344d05c1ac3d0dd10e143a19fa9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:16 -0700 Subject: [PATCH 224/257] [codex] Structure mobile native static-check failures (#3464) Co-authored-by: codex --- scripts/mobile-native-static-check.test.ts | 148 +++++++++++++++++++-- scripts/mobile-native-static-check.ts | 120 ++++++++++++++--- 2 files changed, 238 insertions(+), 30 deletions(-) diff --git a/scripts/mobile-native-static-check.test.ts b/scripts/mobile-native-static-check.test.ts index 9671ffbbc9f..7393b4bd9c8 100644 --- a/scripts/mobile-native-static-check.test.ts +++ b/scripts/mobile-native-static-check.test.ts @@ -1,18 +1,142 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { NativeStaticCheckCommandError } from "./mobile-native-static-check.ts"; +import { collectSources, runCommand } 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, +const processHandle = ( + exitCode: Effect.Effect, +) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode, + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, }); - 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."); +const provideSpawner = (spawn: ChildProcessSpawner.ChildProcessSpawner["Service"]["spawn"]) => + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(spawn)); + +const runSwiftLint = runCommand("swiftlint", ["lint", "--strict"], "/repo/apps/mobile").pipe( + Effect.provideService(HostProcess.HostProcessPlatform, "linux"), +); + +it.layer(NodeServices.layer)("mobile native source discovery", (it) => { + it.effect("preserves the failed discovery operation, path, and exact cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "mobile-native-static-check-" }); + const missingDirectory = path.join(root, "missing"); + + const error = yield* collectSources(missingDirectory, root).pipe(Effect.flip); + + assert.equal(error._tag, "NativeStaticCheckSourceDiscoveryError"); + assert.equal(error.operation, "read-directory"); + assert.equal(error.path, missingDirectory); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, "Native source discovery operation 'read-directory' failed."); + }), + ); +}); + +it.effect("preserves process spawn context and the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "swiftlint was not found", + }); + + return Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide(provideSpawner(() => Effect.fail(cause))), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.equal( + error.message, + "Native static check process operation 'spawn' failed for command 'swiftlint'.", + ); + assert.notProperty(error, "args"); + }); }); + +it.effect("preserves process wait context and the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + description: "status unavailable", + }); + + return Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide(provideSpawner(() => Effect.succeed(processHandle(Effect.fail(cause))))), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "wait-for-exit"); + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.equal( + error.message, + "Native static check process operation 'wait-for-exit' failed for command 'swiftlint'.", + ); + assert.notProperty(error, "args"); + }); +}); + +it.effect("reports non-zero exits without manufacturing a cause", () => + Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide( + provideSpawner(() => + Effect.succeed(processHandle(Effect.succeed(ChildProcessSpawner.ExitCode(2)))), + ), + ), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckCommandError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.exitCode, 2); + assert.notProperty(error, "cause"); + assert.notProperty(error, "args"); + }), +); diff --git a/scripts/mobile-native-static-check.ts b/scripts/mobile-native-static-check.ts index cbdf4be2bd0..8239a092bc2 100644 --- a/scripts/mobile-native-static-check.ts +++ b/scripts/mobile-native-static-check.ts @@ -8,7 +8,6 @@ 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,12 +17,44 @@ interface NativeStaticTool { readonly installHint: string; } +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +export class NativeStaticCheckSourceDiscoveryError extends Schema.TaggedErrorClass()( + "NativeStaticCheckSourceDiscoveryError", + { + operation: Schema.Literals(["resolve-root", "read-directory", "stat-entry"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Native source discovery operation '${this.operation}' failed.`; + } +} + +export class NativeStaticCheckProcessError extends Schema.TaggedErrorClass()( + "NativeStaticCheckProcessError", + { + operation: Schema.Literals(["spawn", "wait-for-exit"]), + command: Schema.String, + argumentCount: NonNegativeInt, + cwd: Schema.String, + shell: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Native static check process operation '${this.operation}' failed for command '${this.command}'.`; + } +} + export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass()( "NativeStaticCheckCommandError", { command: Schema.String, - args: Schema.Array(Schema.String), + argumentCount: NonNegativeInt, cwd: Schema.String, + shell: Schema.Boolean, exitCode: Schema.Int, }, ) { @@ -59,8 +90,17 @@ const excludedDirectories = new Set([ ]); const generatedNativeProjectDirectories = new Set(["android", "ios"]); +const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url); const appRoot = Effect.service(Path.Path).pipe( - Effect.flatMap((path) => path.fromFileUrl(new URL("../apps/mobile", import.meta.url))), + Effect.flatMap((path) => path.fromFileUrl(mobileAppRootUrl)), + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "resolve-root", + path: mobileAppRootUrl.pathname, + cause, + }), + ), ); const commandOutputOptions = { @@ -77,7 +117,7 @@ const warnMissingTool = (tool: NativeStaticTool, checkName: string) => `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`, ); -const runCommand = Effect.fn("runCommand")(function* ( +export const runCommand = Effect.fn("runCommand")(function* ( command: string, args: ReadonlyArray, cwd: string, @@ -85,42 +125,86 @@ const runCommand = Effect.fn("runCommand")(function* ( yield* Console.log(`$ ${[command, ...args].join(" ")}`); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const spawnCommand = yield* resolveSpawnCommand(command, args); - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd, - ...commandOutputOptions, - shell: spawnCommand.shell, - }), + const processContext = { + command, + argumentCount: spawnCommand.args.length, + cwd, + shell: spawnCommand.shell, + } as const; + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd, + ...commandOutputOptions, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckProcessError({ + ...processContext, + operation: "spawn", + cause, + }), + ), + ); + const exitCode = Number( + yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckProcessError({ + ...processContext, + operation: "wait-for-exit", + cause, + }), + ), + ), ); - const exitCode = Number(yield* child.exitCode); if (exitCode !== 0) { return yield* new NativeStaticCheckCommandError({ - command, - args, - cwd, + ...processContext, exitCode, }); } }); -function collectSources( +export function collectSources( directory: string, root: string, ): Effect.Effect< ReadonlyArray, - PlatformError.PlatformError, + NativeStaticCheckSourceDiscoveryError, FileSystem.FileSystem | Path.Path > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const entries = yield* fs.readDirectory(directory); + const entries = yield* fs.readDirectory(directory).pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "read-directory", + path: directory, + cause, + }), + ), + ); const sources: Array = []; for (const entry of entries) { const entryPath = path.join(directory, entry); - const stat = yield* fs.stat(entryPath); + const stat = yield* fs.stat(entryPath).pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "stat-entry", + path: entryPath, + cause, + }), + ), + ); if (stat.type === "Directory") { const isGeneratedNativeProjectDirectory = From d389cfd4e39ec1b76a6898a04d1b90190155b618 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:19 -0700 Subject: [PATCH 225/257] [codex] Structure Azure DevOps CLI failures (#3460) Co-authored-by: codex --- .../src/sourceControl/AzureDevOpsCli.test.ts | 55 +++- .../src/sourceControl/AzureDevOpsCli.ts | 249 ++++++++++++------ .../AzureDevOpsSourceControlProvider.test.ts | 4 +- 3 files changed, 222 insertions(+), 86 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 8617f14e365..1cd4b388552 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -4,8 +4,9 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -345,11 +346,63 @@ describe("AzureDevOpsCli.layer", () => { const az = yield* AzureDevOpsCli.AzureDevOpsCli; const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.operation, "execute"); assert.strictEqual(error.command, "az"); assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.argumentCount, 2); assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); assert.strictEqual(error.cause, cause); assert.equal(error.message.includes("sensitive-upstream-detail"), false); }).pipe(Effect.provide(layer)), ); + + it.effect("does not report a missing working directory as a missing Azure CLI", () => + Effect.gen(function* () { + const cwd = "/missing/repo"; + const platformCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + syscall: "chdir", + pathOrDescriptor: cwd, + }); + const cause = new VcsProcessSpawnError({ + operation: "AzureDevOpsCli.execute", + command: "az", + cwd, + argumentCount: 2, + cause: platformCause, + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.cwd, cwd); + assert.strictEqual(error.cause, cause); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps invalid pull request output diagnostics structured", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.outputLength, 8); + assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); + assert.exists(error.cause); + assert.strictEqual( + error.message, + "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", + ); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index ea0e5286872..609efe4df4c 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,9 +1,11 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, @@ -19,16 +21,61 @@ import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( - "AzureDevOpsCliError", - { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, +const azureDevOpsCommandErrorFields = { + operation: Schema.Literal("execute"), + command: Schema.Literal("az"), + cwd: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsCliUnavailableError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliUnavailableError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCliAuthenticationError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliAuthenticationError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestNotFoundError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass()( + "AzureDevOpsCommandFailedError", + azureDevOpsCommandErrorFields, ) { + get detail(): string { + return "Azure DevOps CLI command failed."; + } + override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } @@ -38,53 +85,109 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestListDecodeError", + { + operation: Schema.Literal("listPullRequests"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid PR list JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestDecodeError", + { + operation: Schema.Literal("getPullRequest"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid pull request JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ + "getRepositoryCloneUrls", + "getDefaultBranch", + "createRepository", +]); + +export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsRepositoryDecodeError", + { + operation: AzureDevOpsRepositoryDecodeOperation, + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid repository JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } +export const AzureDevOpsCliError = Schema.Union([ + AzureDevOpsCliUnavailableError, + AzureDevOpsCliAuthenticationError, + AzureDevOpsPullRequestNotFoundError, + AzureDevOpsCommandFailedError, + AzureDevOpsPullRequestListDecodeError, + AzureDevOpsPullRequestDecodeError, + AzureDevOpsRepositoryDecodeError, +]); +export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; + +export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError); + export interface AzureDevOpsRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -146,17 +249,6 @@ export class AzureDevOpsCli extends Context.Service< } >()("t3/sourceControl/AzureDevOpsCli") {} -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); - } - - return String(error); -} - function normalizeChangeRequestId(reference: string): string { const trimmed = reference.trim().replace(/^#/, ""); const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); @@ -225,19 +317,18 @@ function parseRepositorySpecifier(repository: string): { function decodeAzureDevOpsJson( raw: string, schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, + operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, cwd: string, -): Effect.Effect { +): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( - (error) => - new AzureDevOpsCliError({ + (cause) => + new AzureDevOpsRepositoryDecodeError({ operation, command: "az", cwd, - detail: invalidDetail, - cause: error, + outputLength: raw.length, + cause, }), ), ); @@ -257,8 +348,13 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError((error) => - AzureDevOpsCliError.fromVcsError( - { operation: "execute", command: "az", cwd: input.cwd }, + AzureDevOpsCommandFailedError.fromVcsError( + { + operation: "execute", + command: "az", + cwd: input.cwd, + argumentCount: input.args.length, + }, error, ), ), @@ -297,11 +393,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestListDecodeError({ operation: "listPullRequests", command: "az", cwd: input.cwd, - detail: "Azure DevOps CLI returned invalid PR list JSON.", + outputLength: raw.length, cause: decoded.failure, }), ); @@ -331,11 +427,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestDecodeError({ operation: "getPullRequest", command: "az", cwd: input.cwd, - detail: "Azure DevOps CLI returned invalid pull request JSON.", + outputLength: raw.length, cause: decoded.failure, }), ); @@ -357,7 +453,6 @@ export const make = Effect.gen(function* () { raw, RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", - "Azure DevOps CLI returned invalid repository JSON.", input.cwd, ), ), @@ -383,13 +478,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "createRepository", - "Azure DevOps CLI returned invalid repository JSON.", - input.cwd, - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), ), Effect.map(normalizeRepositoryCloneUrls), ); @@ -421,13 +510,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "getDefaultBranch", - "Azure DevOps CLI returned invalid repository JSON.", - input.cwd, - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), ), diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 1341f4cc08d..21db25e7991 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -48,11 +48,11 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" it.effect("adds change-request context while retaining Azure CLI causes", () => Effect.gen(function* () { - const cause = new AzureDevOpsCli.AzureDevOpsCliError({ + const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ operation: "execute", command: "az", cwd: "/repo", - detail: "Azure DevOps CLI command failed.", + argumentCount: 2, cause: new Error("raw upstream detail that should remain in the cause"), }); const provider = yield* makeProvider({ From 60dc4af916d0a62840d74fe95fe104e8b1c4ac92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:22 -0700 Subject: [PATCH 226/257] [codex] Structure desktop build script failures (#3452) Co-authored-by: codex --- scripts/build-desktop-artifact.test.ts | 45 ++- scripts/build-desktop-artifact.ts | 379 ++++++++++++++++++++----- 2 files changed, 349 insertions(+), 75 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 62823f7fc81..99aea602e8c 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -9,15 +9,17 @@ import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - BuildScriptError, + BuildCommandFailedError, createStageWorkspaceConfig, createStagePnpmConfig, createBuildConfig, DESKTOP_ASAR_UNPACK, InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, + InvalidMockUpdateServerPortError, isMacPasskeySigningConfigurationError, LinuxIconResizeError, + MacPasskeySigningConfigurationResolutionError, MissingMacPasskeyProvisioningProfileError, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, @@ -249,10 +251,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { const aggregateCause = error.cause as AggregateError; assert.lengthOf(aggregateCause.errors, 2); assert.strictEqual(aggregateCause.cause, aggregateCause.errors[0]); - assert.instanceOf(aggregateCause.errors[0], BuildScriptError); - assert.instanceOf(aggregateCause.errors[1], BuildScriptError); - assert.include((aggregateCause.errors[0] as BuildScriptError).message, "magick linux icon"); - assert.include((aggregateCause.errors[1] as BuildScriptError).message, "convert linux icon"); + assert.instanceOf(aggregateCause.errors[0], BuildCommandFailedError); + assert.instanceOf(aggregateCause.errors[1], BuildCommandFailedError); + const primaryError = aggregateCause.errors[0] as BuildCommandFailedError; + const fallbackError = aggregateCause.errors[1] as BuildCommandFailedError; + assert.equal(primaryError.command, "magick linux icon 512x512"); + assert.equal(primaryError.exitCode, 1); + assert.include(primaryError.message, "magick linux icon"); + assert.equal(fallbackError.command, "convert linux icon 512x512"); + assert.equal(fallbackError.exitCode, 2); + assert.include(fallbackError.message, "convert linux icon"); assert.deepStrictEqual( commands.map(({ command }) => command), ["magick", "convert"], @@ -356,7 +364,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { 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); + const error = MacPasskeySigningConfigurationResolutionError.fromCause(knownError); assert.strictEqual(error, knownError); assert.instanceOf(error, InvalidMacPasskeyPublishableKeyError); @@ -367,9 +375,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { 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); + const error = MacPasskeySigningConfigurationResolutionError.fromCause(cause); - assert.instanceOf(error, BuildScriptError); + assert.instanceOf(error, MacPasskeySigningConfigurationResolutionError); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Failed to resolve macOS passkey signing configuration."); assert.notInclude(error.message, secret); @@ -453,6 +461,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it("classifies invalid configured ports with the decoder's number grammar", () => { + const cause = new Error("invalid configured port"); + + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("0x10", cause).reason, + "not-numeric", + ); + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("12.5", cause).reason, + "not-integer", + ); + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("65536", cause).reason, + "out-of-range", + ); + assert.strictEqual( + InvalidMockUpdateServerPortError.fromConfigValue("0x10", cause).cause, + cause, + ); + }); + it.effect("resolves default platform and architecture from host references", () => Effect.gen(function* () { const resolved = yield* resolveBuildOptions({ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f1d03f61509..5a6cbfb8be3 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -18,7 +18,6 @@ import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -126,19 +125,238 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B return yield* getDefaultBuildArch(platform, config); }); -export class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ - readonly message: string; - readonly cause?: unknown; -}> { - static fromMacPasskeySigningConfiguration( +export class MacPasskeySigningConfigurationResolutionError extends Schema.TaggedErrorClass()( + "MacPasskeySigningConfigurationResolutionError", + { + cause: Schema.Defect(), + }, +) { + static fromCause( cause: unknown, - ): MacPasskeySigningConfigurationError | BuildScriptError { + ): MacPasskeySigningConfigurationError | MacPasskeySigningConfigurationResolutionError { return isMacPasskeySigningConfigurationError(cause) ? cause - : new BuildScriptError({ - message: "Failed to resolve macOS passkey signing configuration.", - cause, - }); + : new MacPasskeySigningConfigurationResolutionError({ cause }); + } + + override get message(): string { + return "Failed to resolve macOS passkey signing configuration."; + } +} + +export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedErrorClass()( + "ClerkPasskeyNativePackageMissingError", + { + packageName: Schema.String, + binaryFileName: Schema.String, + packageEntryPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Clerk passkey native package is missing: ${this.packageName}`; + } +} + +export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass()( + "UnsupportedHostBuildPlatformError", + { + hostPlatform: Schema.String, + }, +) { + override get message(): string { + return `Unsupported host platform '${this.hostPlatform}'.`; + } +} + +const InvalidMockUpdateServerPortReason = Schema.Literals([ + "not-numeric", + "not-integer", + "out-of-range", +]); + +export class InvalidMockUpdateServerPortError extends Schema.TaggedErrorClass()( + "InvalidMockUpdateServerPortError", + { + reason: InvalidMockUpdateServerPortReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid mock update server port."; + } + + static fromConfigValue(configuredPort: string, cause: unknown) { + return new InvalidMockUpdateServerPortError({ + reason: invalidMockUpdateServerPortReason(configuredPort), + inputLength: configuredPort.length, + cause, + }); + } +} + +export class BuildCommandFailedError extends Schema.TaggedErrorClass()( + "BuildCommandFailedError", + { + command: Schema.String, + exitCode: Schema.Int, + stdoutTail: Schema.optionalKey(Schema.String), + stderrTail: Schema.optionalKey(Schema.String), + }, +) { + override get message(): string { + const outputSections = [ + `Command: ${this.command}`, + formatOutputSection("stdout", this.stdoutTail ?? ""), + formatOutputSection("stderr", this.stderrTail ?? ""), + ].filter((section): section is string => section !== undefined); + const outputSuffix = outputSections.length > 0 ? `\n\n${outputSections.join("\n\n")}` : ""; + return `Command exited with non-zero exit code (${this.exitCode})${outputSuffix}`; + } +} + +const desktopIconPlatformNames = { + mac: "macOS", + linux: "Linux", + win: "Windows", +} satisfies Record; + +export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass()( + "DesktopIconSourceMissingError", + { + platform: BuildPlatform, + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Desktop ${desktopIconPlatformNames[this.platform]} icon source is missing at ${this.sourcePath}`; + } +} + +export class BundledClientAssetsMissingError extends Schema.TaggedErrorClass()( + "BundledClientAssetsMissingError", + { + indexPath: Schema.String, + missingFiles: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const preview = this.missingFiles.slice(0, 6).join(", "); + const suffix = this.missingFiles.length > 6 ? ` (+${this.missingFiles.length - 6} more)` : ""; + return `Bundled client references missing files in ${this.indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`; + } +} + +export class UnsupportedDesktopBuildPlatformError extends Schema.TaggedErrorClass()( + "UnsupportedDesktopBuildPlatformError", + { + platform: Schema.String, + }, +) { + override get message(): string { + return `Unsupported platform '${this.platform}'.`; + } +} + +const dependencyResolutionDescriptions = { + "server-production": "production dependencies", + "workspace-overrides": "overrides", + "desktop-runtime": "desktop runtime dependencies", +} as const; +const DependencyResolutionKind = Schema.Literals([ + "server-production", + "workspace-overrides", + "desktop-runtime", +]); + +export class DesktopBuildDependencyResolutionError extends Schema.TaggedErrorClass()( + "DesktopBuildDependencyResolutionError", + { + kind: DependencyResolutionKind, + manifestPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not resolve ${dependencyResolutionDescriptions[this.kind]} from ${this.manifestPath}.`; + } +} + +export class MissingServerProductionDependenciesError extends Schema.TaggedErrorClass()( + "MissingServerProductionDependenciesError", + { + manifestPath: Schema.String, + }, +) { + override get message(): string { + return `Could not resolve production dependencies from ${this.manifestPath}.`; + } +} + +const DesktopBuildInputArtifact = Schema.Literals([ + "desktop-dist", + "desktop-resources", + "server-dist", + "bundled-server-client", +]); +type DesktopBuildInputArtifact = typeof DesktopBuildInputArtifact.Type; +const desktopBuildInputArtifactNames = { + "desktop-dist": "desktopDist", + "desktop-resources": "desktopResources", + "server-dist": "serverDist", + "bundled-server-client": "bundled server client", +} satisfies Record; + +export class MissingDesktopBuildInputError extends Schema.TaggedErrorClass()( + "MissingDesktopBuildInputError", + { + artifact: DesktopBuildInputArtifact, + artifactPath: Schema.String, + buildCommand: Schema.Literal("vp run build:desktop"), + }, +) { + override get message(): string { + return `Missing ${desktopBuildInputArtifactNames[this.artifact]} at ${this.artifactPath}. Run '${this.buildCommand}' first.`; + } +} + +export class MacProvisioningProfileNotFoundError extends Schema.TaggedErrorClass()( + "MacProvisioningProfileNotFoundError", + { + provisioningProfilePath: Schema.String, + }, +) { + override get message(): string { + return `macOS provisioning profile not found: ${this.provisioningProfilePath}`; + } +} + +export class DesktopBuildDistDirectoryMissingError extends Schema.TaggedErrorClass()( + "DesktopBuildDistDirectoryMissingError", + { + distPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + }, +) { + override get message(): string { + return `Build completed but dist directory was not found at ${this.distPath}`; + } +} + +export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedErrorClass()( + "DesktopBuildNoArtifactsProducedError", + { + distPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + }, +) { + override get message(): string { + return `Build completed but no files were produced in ${this.distPath}`; } } @@ -616,8 +834,12 @@ const stageClerkPasskeyNativeBinaries = Effect.fn("stageClerkPasskeyNativeBinari const sourcePath = yield* Effect.try({ try: () => packageRequire.resolve(artifact.packageName), catch: (cause) => - new BuildScriptError({ - message: `Clerk passkey native package is missing: ${artifact.packageName}`, + new ClerkPasskeyNativePackageMissingError({ + packageName: artifact.packageName, + binaryFileName: artifact.binaryFileName, + packageEntryPath, + platform, + arch, cause, }), }); @@ -691,6 +913,18 @@ const MockUpdateServerPortSchema = Schema.NumberFromString.check( ); const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema); +function invalidMockUpdateServerPortReason( + configuredPort: string, +): typeof InvalidMockUpdateServerPortReason.Type { + const parsed = Number(configuredPort); + if (!Number.isFinite(parsed)) return "not-numeric"; + if (!Number.isInteger(parsed)) return "not-integer"; + if (parsed < 1 || parsed > 65535) return "out-of-range"; + // This mapper is only called after schema decoding failed. An otherwise + // valid integer therefore used a representation the decoder did not accept. + return "not-numeric"; +} + const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(flag, () => envValue); const mergeOptions = (a: Option.Option, b: Option.Option, defaultValue: A) => @@ -722,9 +956,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( ); if (!platform) { - return yield* new BuildScriptError({ - message: `Unsupported host platform '${hostPlatform}'.`, - }); + return yield* new UnsupportedHostBuildPlatformError({ hostPlatform }); } const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); @@ -745,17 +977,16 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const verbose = resolveBooleanFlag(input.verbose, env.verbose); const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates); + const configuredMockUpdateServerPort = Option.getOrUndefined(env.mockUpdateServerPort); const mockUpdateServerPort = Option.getOrUndefined(input.mockUpdateServerPort) ?? - (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe( - Effect.mapError( - (cause) => - new BuildScriptError({ - message: "Invalid mock update server port.", - cause, - }), - ), - )); + (configuredMockUpdateServerPort === undefined + ? undefined + : yield* resolveMockUpdateServerPort(configuredMockUpdateServerPort).pipe( + Effect.mapError((cause) => + InvalidMockUpdateServerPortError.fromConfigValue(configuredMockUpdateServerPort, cause), + ), + )); return { platform, @@ -775,7 +1006,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const runCommand = Effect.fn("runCommand")(function* ( command: ChildProcess.Command, options: { - readonly label?: string; + readonly label: string; readonly verbose: boolean; }, ) { @@ -791,14 +1022,11 @@ const runCommand = Effect.fn("runCommand")(function* ( ); if (exitCode !== 0) { - const outputSections = [ - options.label ? `Command: ${options.label}` : undefined, - formatOutputSection("stdout", stdout), - formatOutputSection("stderr", stderr), - ].filter((section): section is string => section !== undefined); - const outputSuffix = outputSections.length > 0 ? `\n\n${outputSections.join("\n\n")}` : ""; - return yield* new BuildScriptError({ - message: `Command exited with non-zero exit code (${exitCode})${outputSuffix}`, + return yield* new BuildCommandFailedError({ + command: options.label, + exitCode, + ...(stdout.trim() ? { stdoutTail: stdout } : {}), + ...(stderr.trim() ? { stderrTail: stderr } : {}), }); } }); @@ -845,8 +1073,9 @@ function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: bo const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourcePng))) { - return yield* new BuildScriptError({ - message: `Desktop macOS icon source is missing at ${sourcePng}`, + return yield* new DesktopIconSourceMissingError({ + platform: "mac", + sourcePath: sourcePng, }); } @@ -871,8 +1100,9 @@ function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourcePng))) { - return yield* new BuildScriptError({ - message: `Desktop Linux icon source is missing at ${sourcePng}`, + return yield* new DesktopIconSourceMissingError({ + platform: "linux", + sourcePath: sourcePng, }); } @@ -931,8 +1161,9 @@ function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourceIco))) { - return yield* new BuildScriptError({ - message: `Desktop Windows icon source is missing at ${sourceIco}`, + return yield* new DesktopIconSourceMissingError({ + platform: "win", + sourcePath: sourceIco, }); } @@ -969,10 +1200,9 @@ function validateBundledClientAssets(clientDir: string) { } if (missing.length > 0) { - const preview = missing.slice(0, 6).join(", "); - const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; - return yield* new BuildScriptError({ - message: `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, + return yield* new BundledClientAssetsMissingError({ + indexPath, + missingFiles: missing, }); } }); @@ -1174,8 +1404,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const platformConfig = PLATFORM_CONFIG[options.platform]; if (!platformConfig) { - return yield* new BuildScriptError({ - message: `Unsupported platform '${options.platform}'.`, + return yield* new UnsupportedDesktopBuildPlatformError({ + platform: options.platform, }); } @@ -1183,16 +1413,17 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const serverDependencies = serverPackageJson.dependencies; if (!serverDependencies || Object.keys(serverDependencies).length === 0) { - return yield* new BuildScriptError({ - message: "Could not resolve production dependencies from apps/server/package.json.", + return yield* new MissingServerProductionDependenciesError({ + manifestPath: "apps/server/package.json", }); } const resolvedOverrides = yield* Effect.try({ try: () => resolveCatalogDependencies(workspaceOverrides, workspaceCatalog, "apps/desktop"), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve overrides from pnpm-workspace.yaml.", + new DesktopBuildDependencyResolutionError({ + kind: "workspace-overrides", + manifestPath: "pnpm-workspace.yaml", cause, }), }); @@ -1200,16 +1431,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const resolvedServerDependencies = yield* Effect.try({ try: () => resolveCatalogDependencies(serverDependencies, workspaceCatalog, "apps/server"), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve production dependencies from apps/server/package.json.", + new DesktopBuildDependencyResolutionError({ + kind: "server-production", + manifestPath: "apps/server/package.json", cause, }), }); const resolvedDesktopRuntimeDependencies = yield* Effect.try({ try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve desktop runtime dependencies from apps/desktop/package.json.", + new DesktopBuildDependencyResolutionError({ + kind: "desktop-runtime", + manifestPath: "apps/desktop/package.json", cause, }), }); @@ -1243,17 +1476,25 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); } - for (const [label, dir] of Object.entries(distDirs)) { - if (!(yield* fs.exists(dir))) { - return yield* new BuildScriptError({ - message: `Missing ${label} at ${dir}. Run 'vp run build:desktop' first.`, + const requiredBuildInputs = [ + { artifact: "desktop-dist", artifactPath: distDirs.desktopDist }, + { artifact: "desktop-resources", artifactPath: distDirs.desktopResources }, + { artifact: "server-dist", artifactPath: distDirs.serverDist }, + ] as const; + for (const input of requiredBuildInputs) { + if (!(yield* fs.exists(input.artifactPath))) { + return yield* new MissingDesktopBuildInputError({ + ...input, + buildCommand: "vp run build:desktop", }); } } if (!(yield* fs.exists(bundledClientEntry))) { - return yield* new BuildScriptError({ - message: `Missing bundled server client at ${bundledClientEntry}. Run 'vp run build:desktop' first.`, + return yield* new MissingDesktopBuildInputError({ + artifact: "bundled-server-client", + artifactPath: bundledClientEntry, + buildCommand: "vp run build:desktop", }); } @@ -1285,7 +1526,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.platform === "mac" && options.signed ? yield* Effect.try({ try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })), - catch: BuildScriptError.fromMacPasskeySigningConfiguration, + catch: MacPasskeySigningConfigurationResolutionError.fromCause, }) : undefined; const macPasskeySigning = configuredMacPasskeySigning @@ -1302,8 +1543,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( : undefined; if (macPasskeySigning && macEntitlementsPath) { if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) { - return yield* new BuildScriptError({ - message: `macOS provisioning profile not found: ${macPasskeySigning.provisioningProfilePath}`, + return yield* new MacProvisioningProfileNotFoundError({ + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, }); } yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); @@ -1442,8 +1683,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stageDistDir = path.join(stageAppDir, "dist"); if (!(yield* fs.exists(stageDistDir))) { - return yield* new BuildScriptError({ - message: `Build completed but dist directory was not found at ${stageDistDir}`, + return yield* new DesktopBuildDistDirectoryMissingError({ + distPath: stageDistDir, + platform: options.platform, + arch: options.arch, }); } @@ -1462,8 +1705,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } if (copiedArtifacts.length === 0) { - return yield* new BuildScriptError({ - message: `Build completed but no files were produced in ${stageDistDir}`, + return yield* new DesktopBuildNoArtifactsProducedError({ + distPath: stageDistDir, + platform: options.platform, + arch: options.arch, }); } From 8c2d33abde95fa21595f4e3b49ff3c5ba2e3cab4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:09:02 -0700 Subject: [PATCH 227/257] [codex] Structure release package updater failures (#3468) Co-authored-by: codex --- .../update-release-package-versions.test.ts | 108 +++++++++++++++++- scripts/update-release-package-versions.ts | 96 +++++++++++++++- 2 files changed, 198 insertions(+), 6 deletions(-) diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 802e13f35d9..01a27f3273b 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -1,16 +1,21 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { Command, CliError } from "effect/unstable/cli"; import * as TestConsole from "effect/testing/TestConsole"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { + ReleaseGitHubOutputConfigurationError, + ReleaseGitHubOutputWriteError, + ReleasePackageManifestError, releasePackageFiles, updateReleasePackageVersions, updateReleasePackageVersionsCommand, @@ -103,6 +108,73 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { }), ); + it.effect("preserves manifest read context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-read-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip); + + assert.instanceOf(error, ReleasePackageManifestError); + assert.equal(error.operation, "read"); + assert.equal(error.filePath, filePath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, `Failed to read release package manifest '${filePath}'.`); + }), + ); + + it.effect("preserves manifest decode context and the schema cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-decode-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + yield* fs.writeFileString(filePath, "not json"); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip); + + assert.equal(error.operation, "decode"); + assert.equal(error.filePath, filePath); + assert.isTrue(Schema.isSchemaError(error.cause)); + assert.equal(error.message, `Failed to decode release package manifest '${filePath}'.`); + }), + ); + + it.effect("preserves manifest write context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-write-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + yield* fs.chmod(filePath, 0o400); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip, Effect.ensuring(fs.chmod(filePath, 0o600).pipe(Effect.orDie))); + + assert.equal(error.operation, "write"); + assert.equal(error.filePath, filePath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, `Failed to write release package manifest '${filePath}'.`); + }), + ); + it.effect("accepts flags before the version positional and appends changed output", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -164,9 +236,43 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { Effect.flip, ); + assert.instanceOf(error, ReleaseGitHubOutputConfigurationError); + assert.instanceOf(error.cause, Config.ConfigError); + assert.equal( + error.message, + "Failed to resolve GITHUB_OUTPUT for release package version output.", + ); + }), + ); + + it.effect("preserves GITHUB_OUTPUT write context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-output-error-", + }); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + const error = yield* runCli(["4.0.0", "--root", baseDir, "--github-output"]).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + GITHUB_OUTPUT: baseDir, + }, + }), + ), + ), + Effect.flip, + ); + + assert.instanceOf(error, ReleaseGitHubOutputWriteError); + assert.equal(error.filePath, baseDir); + assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal( error.message, - 'SchemaError(Expected string, got undefined\n at ["GITHUB_OUTPUT"])', + `Failed to append release package version output to '${baseDir}'.`, ); }), ); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index cebf434d0ae..5465b508512 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -12,6 +12,40 @@ import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +export class ReleasePackageManifestError extends Schema.TaggedErrorClass()( + "ReleasePackageManifestError", + { + operation: Schema.Literals(["read", "decode", "encode", "write"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} release package manifest '${this.filePath}'.`; + } +} + +export class ReleaseGitHubOutputConfigurationError extends Schema.TaggedErrorClass()( + "ReleaseGitHubOutputConfigurationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to resolve GITHUB_OUTPUT for release package version output."; + } +} + +export class ReleaseGitHubOutputWriteError extends Schema.TaggedErrorClass()( + "ReleaseGitHubOutputWriteError", + { + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append release package version output to '${this.filePath}'.`; + } +} + export const releasePackageFiles = [ "apps/server/package.json", "apps/desktop/package.json", @@ -39,13 +73,50 @@ export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersi for (const relativePath of releasePackageFiles) { const filePath = path.join(rootDir, relativePath); - const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); + const packageJsonText = yield* fs.readFileString(filePath).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "read", + filePath, + cause, + }), + ), + ); + const packageJson = yield* decodePackageJson(packageJsonText).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "decode", + filePath, + cause, + }), + ), + ); if (packageJson.version === version) { continue; } - const packageJsonString = yield* encodePackageJson({ ...packageJson, version }); - yield* fs.writeFileString(filePath, `${packageJsonString}\n`); + const packageJsonString = yield* encodePackageJson({ ...packageJson, version }).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "encode", + filePath, + cause, + }), + ), + ); + yield* fs.writeFileString(filePath, `${packageJsonString}\n`).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "write", + filePath, + cause, + }), + ), + ); changed = true; } @@ -54,8 +125,23 @@ export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersi const writeGithubOutput = Effect.fn("writeGithubOutput")(function* (changed: boolean) { const fs = yield* FileSystem.FileSystem; - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); - yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new ReleaseGitHubOutputConfigurationError({ + cause, + }), + ), + ); + yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new ReleaseGitHubOutputWriteError({ + filePath: githubOutputPath, + cause, + }), + ), + ); }); export const updateReleasePackageVersionsCommand = Command.make( From 1b2f39d018410a9e09c67e2629f6ca1562860e6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:09:05 -0700 Subject: [PATCH 228/257] [codex] Structure theme synchronization failures (#3466) Co-authored-by: codex --- apps/web/src/hooks/useTheme.test.ts | 193 ++++++++++++++++++++++++++++ apps/web/src/hooks/useTheme.ts | 146 +++++++++++++++++++-- 2 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/hooks/useTheme.test.ts diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts new file mode 100644 index 00000000000..6c814e30165 --- /dev/null +++ b/apps/web/src/hooks/useTheme.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +afterEach(() => { + vi.doUnmock("react"); + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("theme failure handling", () => { + it("preserves exact storage causes and operation context", async () => { + const readCause = new Error("storage read blocked"); + const writeCause = new Error("storage quota exceeded"); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw readCause; + }, + setItem: () => { + throw writeCause; + }, + }), + }); + + const { readThemePreference, ThemeStorageError, writeThemePreference } = + await import("./useTheme"); + + try { + readThemePreference(); + expect.unreachable("expected the theme read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "t3code:theme", + cause: readCause, + }); + } + + try { + writeThemePreference("dark"); + expect.unreachable("expected the theme write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "t3code:theme", + theme: "dark", + cause: writeCause, + }); + } + }); + + it("falls back during initial theme application and logs only safe attributes", async () => { + const cause = new Error("private browsing storage failure"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw cause; + }, + }), + matchMedia: () => ({ matches: false }), + }); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + }, + }); + + await expect(import("./useTheme")).resolves.toBeDefined(); + + expect(errorLog).toHaveBeenCalledWith( + "Failed to read theme preference for t3code:theme.", + expect.objectContaining({ + operation: "read", + storageKey: "t3code:theme", + errorTag: "ThemeStorageError", + }), + ); + const attributes = errorLog.mock.calls[0]?.[1]; + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + }); + + it("retries a failed storage read only after a relevant storage event", async () => { + const cause = new Error("persistent storage failure"); + const getItem = vi.fn(() => { + throw cause; + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + let readSnapshot: (() => unknown) | undefined; + let subscribeToTheme: ((listener: () => void) => () => void) | undefined; + let storageHandler: ((event: StorageEvent) => void) | undefined; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribeToTheme = subscribe; + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: createStorage({ getItem }), + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + + const { useTheme } = await import("./useTheme"); + useTheme(); + readSnapshot?.(); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(1); + expect(errorLog).toHaveBeenCalledTimes(1); + + const unsubscribe = subscribeToTheme?.(() => undefined); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); + + it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => { + const cause = new Error("desktop IPC unavailable"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const setTheme = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", { desktopBridge: { setTheme } }); + + const { DesktopThemeSyncError, syncDesktopTheme, syncDesktopThemePreference } = + await import("./useTheme"); + + const error = await syncDesktopThemePreference({ setTheme }, "dark").then( + () => undefined, + (failure: unknown) => failure, + ); + expect(error).toBeInstanceOf(DesktopThemeSyncError); + expect(error).toMatchObject({ theme: "dark", cause }); + + setTheme.mockClear(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + + expect(setTheme).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledWith( + "Failed to sync the dark theme to the desktop shell.", + expect.objectContaining({ + theme: "dark", + errorTag: "DesktopThemeSyncError", + }), + ); + for (const [, attributes] of errorLog.mock.calls) { + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + } + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index eec2e9c9363..bdaf37f099d 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,11 +1,17 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; -type Theme = "light" | "dark" | "system"; +const ThemePreference = Schema.Literals(["light", "dark", "system"]); +type Theme = typeof ThemePreference.Type; type ThemeSnapshot = { theme: Theme; systemDark: boolean; }; +type DesktopThemeBridge = Pick; + const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { @@ -15,19 +21,46 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; +export class ThemeStorageError extends Schema.TaggedErrorClass()( + "ThemeStorageError", + { + operation: Schema.Literals(["read", "write"]), + storageKey: Schema.String, + theme: Schema.optional(ThemePreference), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; + } +} + +export const isThemeStorageError = Schema.is(ThemeStorageError); + +export class DesktopThemeSyncError extends Schema.TaggedErrorClass()( + "DesktopThemeSyncError", + { + theme: ThemePreference, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sync the ${this.theme} theme to the desktop shell.`; + } +} + +export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); + let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; let lastAppliedTheme: ThemeSnapshot | null = null; +let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() { for (const listener of listeners) listener(); } -function hasThemeStorage() { - return typeof window !== "undefined" && typeof localStorage !== "undefined"; -} - function getSystemDark() { return ( typeof window !== "undefined" && @@ -36,13 +69,61 @@ function getSystemDark() { ); } -function getStored(): Theme { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; - const raw = localStorage.getItem(STORAGE_KEY); +export function readThemePreference(): Theme { + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; + let raw: string | null; + try { + raw = window.localStorage.getItem(STORAGE_KEY); + } catch (cause) { + throw new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + } if (raw === "light" || raw === "dark" || raw === "system") return raw; return DEFAULT_THEME_SNAPSHOT.theme; } +export function writeThemePreference(theme: Theme): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, theme); + themeStorageReadFailure = null; + } catch (cause) { + throw new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme, + cause, + }); + } +} + +function getStored(): Theme { + if (themeStorageReadFailure !== null) { + return DEFAULT_THEME_SNAPSHOT.theme; + } + try { + return readThemePreference(); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + themeStorageReadFailure = error; + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + ...safeErrorLogAttributes(error), + }); + return DEFAULT_THEME_SNAPSHOT.theme; + } +} + function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); if (element) { @@ -118,7 +199,18 @@ function applyTheme(theme: Theme, suppressTransitions = false) { } } -function syncDesktopTheme(theme: Theme) { +export async function syncDesktopThemePreference( + bridge: DesktopThemeBridge, + theme: Theme, +): Promise { + try { + await bridge.setTheme(theme); + } catch (cause) { + throw new DesktopThemeSyncError({ theme, cause }); + } +} + +export function syncDesktopTheme(theme: Theme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { @@ -126,7 +218,14 @@ function syncDesktopTheme(theme: Theme) { } lastDesktopTheme = theme; - void bridge.setTheme(theme).catch(() => { + void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { + const error = isDesktopThemeSyncError(cause) + ? cause + : new DesktopThemeSyncError({ theme, cause }); + console.error(error.message, { + theme: error.theme, + ...safeErrorLogAttributes(error), + }); if (lastDesktopTheme === theme) { lastDesktopTheme = null; } @@ -134,12 +233,12 @@ function syncDesktopTheme(theme: Theme) { } // Apply immediately on module load to prevent flash -if (typeof document !== "undefined" && hasThemeStorage()) { +if (typeof document !== "undefined" && typeof window !== "undefined") { applyTheme(getStored()); } function getSnapshot(): ThemeSnapshot { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; const theme = getStored(); const systemDark = theme === "system" ? getSystemDark() : false; @@ -170,6 +269,7 @@ function subscribe(listener: () => void): () => void { // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY) { + themeStorageReadFailure = null; applyTheme(getStored(), true); emitChange(); } @@ -191,8 +291,26 @@ export function useTheme() { theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { - if (!hasThemeStorage()) return; - localStorage.setItem(STORAGE_KEY, next); + if (typeof window === "undefined") return; + try { + writeThemePreference(next); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme: next, + cause, + }); + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + theme: next, + ...safeErrorLogAttributes(error), + }); + return; + } applyTheme(next, true); emitChange(); }, []); From 61e6d89d69240120dd08fae72459e5fd7e7a2908 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:14:35 -0700 Subject: [PATCH 229/257] [codex] Structure GitHub CLI failures (#3456) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 56 ++-- .../src/sourceControl/GitHubCli.test.ts | 26 +- apps/server/src/sourceControl/GitHubCli.ts | 270 +++++++++++------- .../GitHubSourceControlProvider.test.ts | 4 +- .../GitHubSourceControlProvider.ts | 14 +- 5 files changed, 212 insertions(+), 158 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c06915c51b9..e1924c03ade 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -171,20 +171,8 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { if (result.status === 0) { return; } - throw new GitHubCli.GitHubCliError({ - operation: "execute", - command: "gh", - cwd, - detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, - }); -} - -function isGitHubCliError(error: unknown): error is GitHubCli.GitHubCliError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - (error as { _tag?: unknown })._tag === "GitHubCliError" + throw new Error( + `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, ); } @@ -478,16 +466,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - isGitHubCliError(error) + GitHubCli.isGitHubCliError(error) ? error - : new GitHubCli.GitHubCliError({ - operation: "execute", + : new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, - detail: - error instanceof Error - ? `Failed to simulate gh checkout: ${error.message}` - : "Failed to simulate gh checkout.", + cause: error, }), }); } @@ -498,11 +482,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCli.GitHubCliError({ - operation: "execute", + new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, - detail: `Unexpected repository lookup: ${repository}`, + cause: new Error(`Unexpected repository lookup: ${repository}`), }), ); } @@ -520,11 +503,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCli.GitHubCliError({ - operation: "execute", + new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, - detail: `Unexpected gh command: ${args.join(" ")}`, + cause: new Error(`Unexpected gh command: ${args.join(" ")}`), }), ); }; @@ -601,11 +583,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCli.GitHubCliError({ - operation: "createRepository", + new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, - detail: `Unexpected repository create: ${input.repository}`, + cause: new Error(`Unexpected repository create: ${input.repository}`), }), ), checkoutPullRequest: (input) => @@ -1341,11 +1322,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliError({ - operation: "execute", + failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: new Error("gh is not available on PATH"), }), }, }); @@ -2483,11 +2463,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliError({ - operation: "execute", + failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: new Error("gh is not available on PATH"), }), }, }); @@ -2514,11 +2493,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliError({ - operation: "execute", + failWith: new GitHubCli.GitHubCliAuthenticationError({ command: "gh", cwd: repoDir, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: new Error("gh is not authenticated"), }), }, }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 7c8c9b037be..5df4862b409 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,8 +1,9 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -30,6 +31,27 @@ afterEach(() => { }); describe("GitHubCli.layer", () => { + it("does not classify a missing cwd as an unavailable gh executable", () => { + const context = { command: "gh", cwd: "/repo" } as const; + const missingCwd = new VcsProcessSpawnError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: context.cwd, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "access", + pathOrDescriptor: context.cwd, + }), + }); + + const commandFailure = GitHubCli.fromVcsError(context, missingCwd); + + assert.equal(commandFailure._tag, "GitHubCliCommandError"); + assert.strictEqual(commandFailure.cause, missingCwd); + assert.notProperty(commandFailure, "operation"); + }); + it.effect("parses pull request view output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -274,6 +296,7 @@ describe("GitHubCli.layer", () => { command: "gh pr view", cwd: "/repo", exitCode: 1, + failureKind: "not-found", detail: "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", }); @@ -288,6 +311,7 @@ describe("GitHubCli.layer", () => { .pipe(Effect.flip); assert.equal(error.message.includes("Pull request not found"), true); + assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); assert.strictEqual(error.command, "gh"); assert.strictEqual(error.cwd, "/repo"); assert.strictEqual(error.cause, cause); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 4cdf38ec2b8..bf3f27378b5 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.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 PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -18,69 +19,165 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; -export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { - operation: Schema.String, - command: Schema.String, +const gitHubCliFailureFields = { + command: Schema.Literal("gh"), cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { + cause: Schema.Defect(), +} as const; + +export class GitHubCliUnavailableError extends Schema.TaggedErrorClass()( + "GitHubCliUnavailableError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI (`gh`) is required but not available on PATH."; + } + override get message(): string { - return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + return `GitHub CLI failed in execute: ${this.detail}`; } +} - static fromVcsError( - context: { - readonly operation: "execute"; - readonly command: "gh"; - readonly cwd: string; - }, - error: VcsError | unknown, - ): GitHubCliError { - const lower = errorText(error).toLowerCase(); - - if (lower.includes("command not found: gh") || lower.includes("enoent")) { - return new GitHubCliError({ - ...context, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); - } +export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitHubCliAuthenticationError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; + } - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - ...context, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); - } + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} - if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") - ) { - return new GitHubCliError({ - ...context, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); - } +export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubPullRequestNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + +export class GitHubCliCommandError extends Schema.TaggedErrorClass()( + "GitHubCliCommandError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI command failed."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + +const gitHubCliDecodeFields = { + command: Schema.Literal("gh"), + cwd: Schema.String, + cause: Schema.Defect(), +} as const; + +export class GitHubPullRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid PR list JSON."; + } + + override get message(): string { + return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`; + } +} + +export class GitHubChangeRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitHubChangeRequestListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid change request JSON."; + } - return new GitHubCliError({ - ...context, - detail: "GitHub CLI command failed.", - cause: error, - }); + override get message(): string { + return `GitHub CLI failed in listChangeRequests: ${this.detail}`; } } +export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request JSON."; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequest: ${this.detail}`; + } +} + +export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitHubRepositoryDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository JSON."; + } + + override get message(): string { + return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`; + } +} + +export const GitHubCliError = Schema.Union([ + GitHubCliUnavailableError, + GitHubCliAuthenticationError, + GitHubPullRequestNotFoundError, + GitHubCliCommandError, + GitHubPullRequestListDecodeError, + GitHubChangeRequestListDecodeError, + GitHubPullRequestDecodeError, + GitHubRepositoryDecodeError, +]); +export type GitHubCliError = typeof GitHubCliError.Type; + +export const isGitHubCliError = Schema.is(GitHubCliError); + +export function fromVcsError( + context: { + readonly command: "gh"; + readonly cwd: string; + }, + error: VcsError, +): GitHubCliError { + if ( + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" && + error.cause.reason.module === "ChildProcess" && + error.cause.reason.method === "spawn" + ) { + return new GitHubCliUnavailableError({ ...context, cause: error }); + } + + if (error._tag === "VcsProcessExitError") { + if (error.failureKind === "authentication") { + return new GitHubCliAuthenticationError({ ...context, cause: error }); + } + if (error.failureKind === "not-found") { + return new GitHubPullRequestNotFoundError({ ...context, cause: error }); + } + } + + return new GitHubCliCommandError({ ...context, cause: error }); +} + export interface GitHubPullRequestSummary { readonly number: number; readonly title: string; @@ -150,22 +247,14 @@ export class GitHubCli extends Context.Service< } >()("t3/sourceControl/GitHubCli") {} -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); - } - - return String(error); -} - const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); +const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), +); function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, @@ -214,27 +303,6 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } -function decodeGitHubJson( - raw: string, - schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", - invalidDetail: string, - cwd: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitHubCliError({ - operation, - command: "gh", - cwd, - detail: invalidDetail, - cause: error, - }), - ), - ); -} - export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; @@ -247,14 +315,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe( - Effect.mapError((error) => - GitHubCliError.fromVcsError( - { operation: "execute", command: "gh", cwd: input.cwd }, - error, - ), - ), - ); + .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); return GitHubCli.of({ execute, @@ -282,11 +343,9 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubCliError({ - operation: "listOpenPullRequests", + new GitHubPullRequestListDecodeError({ command: "gh", cwd: input.cwd, - detail: "GitHub CLI returned invalid PR list JSON.", cause: decoded.failure, }), ); @@ -316,11 +375,9 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubCliError({ - operation: "getPullRequest", + new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, - detail: "GitHub CLI returned invalid pull request JSON.", cause: decoded.failure, }), ); @@ -340,12 +397,15 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitHubJson( - raw, - RawGitHubRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitHub CLI returned invalid repository JSON.", - input.cwd, + decodeRawGitHubRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositoryDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index c1aa8680b26..9e8a6829566 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -70,11 +70,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = it.effect("adds safe request context while retaining GitHub CLI causes", () => Effect.gen(function* () { - const cause = new GitHubCli.GitHubCliError({ - operation: "execute", + const cause = new GitHubCli.GitHubPullRequestNotFoundError({ command: "gh", cwd: "/repo", - detail: "Pull request not found. Check the PR number or URL and try again.", cause: new Error("raw upstream detail that should remain in the cause"), }); const provider = yield* makeProvider({ diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 60298888e6c..b5d5d3a55f8 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -158,23 +158,17 @@ export const make = Effect.gen(function* () { })), ) : Effect.fail( - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", + new GitHubCli.GitHubChangeRequestListDecodeError({ command: "gh", cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: "GitHub CLI returned invalid change request JSON.", cause: decoded.failure, }), ), ), ); }), - Effect.catchTags({ - GitHubCliError: (error) => + Effect.mapError( + (error) => new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", @@ -186,7 +180,7 @@ export const make = Effect.gen(function* () { detail: error.detail, cause: error, }), - }), + ), ); }; From e4a84a348e59d59e03bca15baa627aed99aec790 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:20:01 -0700 Subject: [PATCH 230/257] [codex] Structure release output failures (#3470) Co-authored-by: codex --- scripts/resolve-nightly-release.test.ts | 26 ++++++ scripts/resolve-nightly-release.ts | 48 +++++++++-- scripts/resolve-previous-release-tag.test.ts | 89 +++++++++++++++++++- scripts/resolve-previous-release-tag.ts | 60 ++++++++++--- 4 files changed, 205 insertions(+), 18 deletions(-) diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index 18d6a2a2ed7..dda1e7081be 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -10,6 +12,7 @@ import { resolveNightlyBaseVersion, resolveNightlyReleaseMetadata, resolveNightlyTargetVersion, + writeNightlyReleaseOutput, } from "./resolve-nightly-release.ts"; it("strips prerelease and build metadata when deriving the nightly base version", () => { @@ -49,6 +52,29 @@ it("derives nightly metadata including the short commit sha in the release name" ); }); +it.effect("preserves the GITHUB_OUTPUT configuration cause", () => { + const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890"); + const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" }); + + return Effect.gen(function* () { + const configError = yield* writeNightlyReleaseOutput(metadata, true).pipe( + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.make(() => Effect.fail(configCause)), + ), + Effect.flip, + ); + + if (configError._tag !== "NightlyReleaseGitHubOutputConfigError") { + return assert.fail(`Unexpected error: ${configError._tag}`); + } + assert.instanceOf(configError.cause, Config.ConfigError); + assert.strictEqual(configError.cause.cause, configCause); + assert.notInclude(configError.message, configCause.message); + }); +}); + it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => { it.effect("preserves desktop package read context and its platform cause", () => Effect.gen(function* () { diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index adad8c6f4f8..5b42f931d7b 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -11,7 +11,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; -interface NightlyReleaseMetadata { +export interface NightlyReleaseMetadata { readonly baseVersion: string; readonly version: string; readonly tag: string; @@ -53,6 +53,29 @@ export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass()( + "NightlyReleaseGitHubOutputConfigError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve the GITHUB_OUTPUT path for nightly release metadata."; + } +} + +export class NightlyReleaseGitHubOutputAppendError extends Schema.TaggedErrorClass()( + "NightlyReleaseGitHubOutputAppendError", + { + outputPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append nightly release metadata to ${this.outputPath}.`; + } +} + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -120,7 +143,7 @@ export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(functi return yield* resolveNightlyTargetVersion(packageJson.version); }); -const writeOutput = Effect.fn("writeOutput")(function* ( +export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* ( metadata: NightlyReleaseMetadata, writeGithubOutput: boolean, ) { @@ -135,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* ( ] as const; if (writeGithubOutput) { - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new NightlyReleaseGitHubOutputConfigError({ + cause, + }), + ), + ); const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join(""); - yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }); + yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseGitHubOutputAppendError({ + outputPath: githubOutputPath, + cause, + }), + ), + ); } else { for (const [key, value] of entries) { yield* Console.log(`${key}=${value}`); @@ -172,7 +210,7 @@ const command = Command.make( ({ date, runNumber, sha, githubOutput, root }) => readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), - Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)), + Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)), ), ).pipe(Command.withDescription("Resolve nightly release version metadata.")); diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts index a9c4832c26a..5fe06d2af54 100644 --- a/scripts/resolve-previous-release-tag.test.ts +++ b/scripts/resolve-previous-release-tag.test.ts @@ -1,11 +1,17 @@ import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; +import { + listGitTags, + resolvePreviousReleaseTag, + writePreviousReleaseTagOutput, +} from "./resolve-previous-release-tag.ts"; const encoder = new TextEncoder(); @@ -13,6 +19,8 @@ function mockHandle(options: { readonly exitCode: number; readonly stdout?: string; readonly stderr?: string; + readonly stdoutError?: PlatformError.PlatformError; + readonly stderrError?: PlatformError.PlatformError; }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -21,8 +29,12 @@ function mockHandle(options: { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode(options.stdout ?? "")), - stderr: Stream.make(encoder.encode(options.stderr ?? "")), + stdout: options.stdoutError + ? Stream.fail(options.stdoutError) + : Stream.make(encoder.encode(options.stdout ?? "")), + stderr: options.stderrError + ? Stream.fail(options.stderrError) + : Stream.make(encoder.encode(options.stderr ?? "")), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -95,6 +107,43 @@ it.effect("preserves git tag spawn context and the exact platform cause", () => }); }); +it.effect("distinguishes stdout and stderr read failures", () => + Effect.gen(function* () { + for (const [stream, operation] of [ + ["stdout", "read-stdout"], + ["stderr", "read-stderr"], + ] as const) { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: stream, + description: `${stream} unavailable`, + }); + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + exitCode: 0, + ...(stream === "stdout" ? { stdoutError: cause } : { stderrError: cause }), + }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, operation); + assert.strictEqual(error.cause, cause); + } + }), +); + it.effect("reports git tag non-zero exits without manufacturing a cause", () => Effect.gen(function* () { const error = yield* listGitTags("/repo").pipe( @@ -128,3 +177,37 @@ it.effect("reports git tag non-zero exits without manufacturing a cause", () => assert.notProperty(error, "stderr"); }), ); + +it.effect("preserves the GITHUB_OUTPUT append path and exact cause", () => { + const outputPath = "/tmp/previous-tag-github-output"; + const appendCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + pathOrDescriptor: outputPath, + }); + + return Effect.gen(function* () { + const appendError = yield* writePreviousReleaseTagOutput("v1.2.3", true).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: () => Effect.fail(appendCause), + }), + ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env: { GITHUB_OUTPUT: outputPath } }), + ), + Effect.flip, + ); + + if (appendError._tag !== "PreviousReleaseTagGitHubOutputAppendError") { + return assert.fail(`Unexpected error: ${appendError._tag}`); + } + assert.equal(appendError.outputPath, outputPath); + assert.strictEqual(appendError.cause, appendCause); + assert.notProperty(appendError, "contents"); + assert.notInclude(appendError.message, appendCause.message); + }); +}); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 83b6e65d1e1..7acc6f456b8 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -26,9 +26,11 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( + "PreviousReleaseTagGitHubOutputConfigError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve the GITHUB_OUTPUT path for the previous release tag."; + } +} + +export class PreviousReleaseTagGitHubOutputAppendError extends Schema.TaggedErrorClass()( + "PreviousReleaseTagGitHubOutputAppendError", + { + outputPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append the previous release tag to ${this.outputPath}.`; + } +} + interface StableVersion { readonly major: number; readonly minor: number; @@ -238,7 +263,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd (cause) => new ReleaseTagListProcessError({ ...context, - operation: "communicate", + operation: "read-stdout", cause, }), ), @@ -248,7 +273,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd (cause) => new ReleaseTagListProcessError({ ...context, - operation: "communicate", + operation: "read-stderr", cause, }), ), @@ -280,7 +305,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty); }); -const writeOutput = Effect.fn("writeOutput")(function* ( +export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* ( previousTag: string | undefined, writeGithubOutput: boolean, ) { @@ -288,8 +313,23 @@ const writeOutput = Effect.fn("writeOutput")(function* ( if (writeGithubOutput) { const fs = yield* FileSystem.FileSystem; - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); - yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new PreviousReleaseTagGitHubOutputConfigError({ + cause, + }), + ), + ); + yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new PreviousReleaseTagGitHubOutputAppendError({ + outputPath: githubOutputPath, + cause, + }), + ), + ); return; } @@ -313,7 +353,7 @@ const command = Command.make( ({ channel, currentTag, githubOutput }) => listGitTags().pipe( Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), - Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)), + Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)), ), ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); From cebbe6ff193c9598bf77c4f673a933db34ed7bcb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:20:04 -0700 Subject: [PATCH 231/257] [codex] Preserve child process termination context (#3469) Co-authored-by: codex --- .../effect-acp/src/_internal/stdio.test.ts | 45 +++++++++++++++++ packages/effect-acp/src/_internal/stdio.ts | 10 +++- packages/effect-acp/src/errors.ts | 2 + .../src/_internal/stdio.test.ts | 48 +++++++++++++++++++ .../src/_internal/stdio.ts | 10 +++- .../effect-codex-app-server/src/errors.ts | 2 + 6 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 packages/effect-acp/src/_internal/stdio.test.ts create mode 100644 packages/effect-codex-app-server/src/_internal/stdio.test.ts diff --git a/packages/effect-acp/src/_internal/stdio.test.ts b/packages/effect-acp/src/_internal/stdio.test.ts new file mode 100644 index 00000000000..8a4171f7a8d --- /dev/null +++ b/packages/effect-acp/src/_internal/stdio.test.ts @@ -0,0 +1,45 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as AcpError from "../errors.ts"; +import { makeTerminationError } from "./stdio.ts"; + +describe("ACP child process termination", () => { + it.effect("retains the process identifier with the exit code", () => + Effect.gen(function* () { + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(41), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(7)), + }); + + assert.instanceOf(error, AcpError.AcpProcessExitedError); + assert.equal(error.pid, 41); + assert.equal(error.code, 7); + assert.equal(error.message, "ACP process exited with code 7"); + }), + ); + + it.effect("retains the process identifier and exact exit-status cause", () => + Effect.gen(function* () { + const rootCause = new Error("private process diagnostics"); + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + cause: rootCause, + }); + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Effect.fail(cause), + }); + + assert.instanceOf(error, AcpError.AcpTransportError); + assert.equal(error.pid, 42); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "ACP transport operation read-process-exit-status failed."); + assert.notInclude(error.message, rootCause.message); + }), + ); +}); diff --git a/packages/effect-acp/src/_internal/stdio.ts b/packages/effect-acp/src/_internal/stdio.ts index 393a1c591cb..87af633637a 100644 --- a/packages/effect-acp/src/_internal/stdio.ts +++ b/packages/effect-acp/src/_internal/stdio.ts @@ -44,14 +44,20 @@ export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () { }; }); +type ChildProcessTerminationHandle = Pick< + ChildProcessSpawner.ChildProcessHandle, + "exitCode" | "pid" +>; + export const makeTerminationError = ( - handle: ChildProcessSpawner.ChildProcessHandle, + handle: ChildProcessTerminationHandle, ): Effect.Effect => Effect.match(handle.exitCode, { onFailure: (cause) => new AcpError.AcpTransportError({ operation: "read-process-exit-status", + pid: handle.pid, cause, }), - onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }), + onSuccess: (code) => new AcpError.AcpProcessExitedError({ code, pid: handle.pid }), }); diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 3fe0a469001..f92568a1483 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -91,6 +91,7 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass { + it.effect("retains the process identifier with the exit code", () => + Effect.gen(function* () { + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(51), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9)), + }); + + assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); + assert.equal(error.pid, 51); + assert.equal(error.code, 9); + assert.equal(error.message, "Codex App Server process exited with code 9"); + }), + ); + + it.effect("retains the process identifier and exact exit-status cause", () => + Effect.gen(function* () { + const rootCause = new Error("private process diagnostics"); + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + cause: rootCause, + }); + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(52), + exitCode: Effect.fail(cause), + }); + + assert.instanceOf(error, CodexError.CodexAppServerTransportError); + assert.equal(error.pid, 52); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Codex App Server transport operation 'read-process-exit-status' failed.", + ); + assert.notInclude(error.message, rootCause.message); + }), + ); +}); diff --git a/packages/effect-codex-app-server/src/_internal/stdio.ts b/packages/effect-codex-app-server/src/_internal/stdio.ts index 9167129db5c..312022824cb 100644 --- a/packages/effect-codex-app-server/src/_internal/stdio.ts +++ b/packages/effect-codex-app-server/src/_internal/stdio.ts @@ -44,14 +44,20 @@ export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () { }; }); +type ChildProcessTerminationHandle = Pick< + ChildProcessSpawner.ChildProcessHandle, + "exitCode" | "pid" +>; + export const makeTerminationError = ( - handle: ChildProcessSpawner.ChildProcessHandle, + handle: ChildProcessTerminationHandle, ): Effect.Effect => Effect.match(handle.exitCode, { onFailure: (cause) => new CodexError.CodexAppServerTransportError({ operation: "read-process-exit-status", + pid: handle.pid, cause, }), - onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }), + onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code, pid: handle.pid }), }); diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 3826a099229..f0e0945d352 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -146,6 +146,7 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 20:19:59 -0700 Subject: [PATCH 232/257] [codex] Structure VCS process boundary errors (#3476) Co-authored-by: codex --- apps/server/src/sourceControl/GitLabCli.ts | 5 +- apps/server/src/vcs/VcsProcess.test.ts | 65 ++++++++++++ apps/server/src/vcs/VcsProcess.ts | 26 ++++- packages/contracts/src/vcs.ts | 109 +++++++++++---------- 4 files changed, 145 insertions(+), 60 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 3e3bbe742c1..a2926afd0ef 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -133,7 +133,10 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass new GitLabCliCommandError({ ...context, cause }), - VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index e13120b1c57..675d20cb82c 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -11,6 +11,7 @@ import { VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; +import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; const run = (input: VcsProcess.VcsProcessInput) => @@ -24,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const provideLive = (effect: Effect.Effect) => effect.pipe(Effect.provide(liveLayer)); +const baseInput = { + operation: "test.process-boundary", + command: "git", + args: ["status", "--short"], + cwd: "/workspace", +} satisfies VcsProcess.VcsProcessInput; + +const captureProcessResult = ( + result: Effect.Effect, +) => + VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: () => result }), + ), + Effect.flatMap((service) => service.run(baseInput)), + Effect.flip, + ); + describe("VcsProcess.run", () => { it.effect("collects stdout", () => Effect.gen(function* () { @@ -141,6 +161,51 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("preserves real boundary causes without manufacturing structural ones", () => + Effect.gen(function* () { + const cause = new Error("secret stdin failure"); + const error = yield* captureProcessResult( + Effect.fail( + new ProcessRunner.ProcessStdinError({ + command: baseInput.command, + argumentCount: baseInput.args.length, + cwd: baseInput.cwd, + stdinBytes: 47, + cause, + }), + ), + ); + + expect(error).toMatchObject({ + _tag: "VcsProcessStdinWriteError", + operation: baseInput.operation, + stdinBytes: 47, + cause, + }); + expect(error.message).not.toContain(cause.message); + + const missingExitCodeError = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + expect(missingExitCodeError).toMatchObject({ + _tag: "VcsProcessMissingExitCodeError", + operation: baseInput.operation, + command: baseInput.command, + cwd: baseInput.cwd, + argumentCount: baseInput.args.length, + }); + expect(missingExitCodeError).not.toHaveProperty("cause"); + }), + ); + it.effect("returns output when non-zero exits are allowed", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 8103c7306a0..52db6f9b1fb 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -5,11 +5,14 @@ import * as Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - VcsOutputDecodeError, type VcsError, VcsProcessExitError, type VcsProcessExitFailureKind, + VcsProcessMissingExitCodeError, + VcsProcessOutputLimitError, + VcsProcessOutputReadError, VcsProcessSpawnError, + VcsProcessStdinWriteError, VcsProcessTimeoutError, } from "@t3tools/contracts"; import * as ProcessRunner from "../processRunner.ts"; @@ -114,19 +117,32 @@ export const make = Effect.gen(function* () { ProcessSpawnError: (error) => VcsProcessSpawnError.fromProcessSpawnError(baseError, error), ProcessOutputLimitError: (error) => - VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), + new VcsProcessOutputLimitError({ + ...baseError, + stream: error.stream, + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }), ProcessTimeoutError: (error) => VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), ProcessStdinError: (error) => - VcsOutputDecodeError.fromProcessStdinError(baseError, error), + new VcsProcessStdinWriteError({ + ...baseError, + stdinBytes: error.stdinBytes, + cause: error.cause, + }), ProcessReadError: (error) => - VcsOutputDecodeError.fromProcessReadError(baseError, error), + new VcsProcessOutputReadError({ + ...baseError, + stream: error.stream, + cause: error.cause, + }), }), ), ); if (result.code === null) { - return yield* VcsOutputDecodeError.missingExitCode(baseError); + return yield* new VcsProcessMissingExitCodeError(baseError); } if (!input.allowNonZeroExit && result.code !== 0) { diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 728cef3974f..c1090f4f39a 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -69,20 +69,6 @@ export interface VcsProcessSpawnFailure { readonly cause: unknown; } -export interface VcsProcessStdinFailure { - readonly cause: unknown; -} - -export interface VcsProcessReadFailure { - readonly stream: "stdout" | "stderr" | "exitCode"; - readonly cause: unknown; -} - -export interface VcsProcessOutputLimitFailure { - readonly stream: "stdout" | "stderr"; - readonly maxBytes: number; -} - export interface VcsProcessTimeoutFailure { readonly timeoutMs: number; } @@ -189,58 +175,70 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass()( - "VcsOutputDecodeError", +const VcsProcessBoundaryErrorFields = { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + argumentCount: Schema.optional(NonNegativeInt), +}; + +export class VcsProcessStdinWriteError extends Schema.TaggedErrorClass()( + "VcsProcessStdinWriteError", { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - argumentCount: Schema.optional(NonNegativeInt), - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + ...VcsProcessBoundaryErrorFields, + stdinBytes: NonNegativeInt, + cause: Schema.Defect(), }, ) { override get message(): string { - return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } - - static fromProcessStdinError(context: VcsProcessErrorContext, error: VcsProcessStdinFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: "failed to write process stdin", - cause: error.cause, - }); + return `VCS process failed to write ${this.stdinBytes} bytes to stdin in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: - error.stream === "exitCode" - ? "failed to read process exit code" - : `failed to read process ${error.stream}`, - cause: error.cause, - }); +export class VcsProcessOutputReadError extends Schema.TaggedErrorClass()( + "VcsProcessOutputReadError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr", "exitCode"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `VCS process failed to read ${this.stream} in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessOutputLimitError( - context: VcsProcessErrorContext, - error: VcsProcessOutputLimitFailure, - ) { - return new VcsOutputDecodeError({ - ...context, - detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, - }); +export class VcsProcessOutputLimitError extends Schema.TaggedErrorClass()( + "VcsProcessOutputLimitError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr"]), + maxBytes: NonNegativeInt, + observedBytes: NonNegativeInt, + }, +) { + override get message(): string { + return `VCS process ${this.stream} produced ${this.observedBytes} bytes in ${this.operation}: ${this.command} (${this.cwd}), exceeding the ${this.maxBytes} byte limit`; } +} - static missingExitCode(context: VcsProcessErrorContext) { - return new VcsOutputDecodeError({ - ...context, - detail: "process completed without an exit code", - }); +export class VcsProcessMissingExitCodeError extends Schema.TaggedErrorClass()( + "VcsProcessMissingExitCodeError", + VcsProcessBoundaryErrorFields, +) { + override get message(): string { + return `VCS process completed without an exit code in ${this.operation}: ${this.command} (${this.cwd})`; } } +export const VcsOutputDecodeError = Schema.Union([ + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, +]); +export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type; + export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass()( "VcsRepositoryDetectionError", { @@ -272,7 +270,10 @@ export const VcsError = Schema.Union([ VcsProcessSpawnError, VcsProcessExitError, VcsProcessTimeoutError, - VcsOutputDecodeError, + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, ]); From e3970e755b0da65c3fd83a985cede1840164ae0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:01 -0700 Subject: [PATCH 233/257] [codex] Preserve APNs delivery failure context (#3475) Co-authored-by: codex --- .../src/agentActivity/ApnsDeliveries.test.ts | 37 ++++++- .../relay/src/agentActivity/ApnsDeliveries.ts | 104 +++++++++++++++--- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 207f4b21417..da3c39cfa71 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -7,7 +7,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Redacted from "effect/Redacted"; +import * as References from "effect/References"; import { FetchHttpClient, HttpClient, @@ -629,6 +631,17 @@ describe("ApnsDeliveries", () => { it.effect("processes signed jobs through APNs and records attempts", () => { const attempts: Array = []; + const transportErrors: Array = []; + const logger = Logger.make(({ fiber }) => { + const annotation = fiber.getRef(References.CurrentLogAnnotations).error; + if (!Redacted.isRedacted(annotation)) { + return; + } + const error = Redacted.value(annotation); + if (ApnsDeliveries.isApnsDeliveryTransportError(error)) { + transportErrors.push(error); + } + }); const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_update", userId: target.user_id, @@ -657,7 +670,29 @@ describe("ApnsDeliveries", () => { token: "activity-token", }, ]); - }).pipe(Effect.provide(makeLayer({ attempts }))); + expect(transportErrors).toHaveLength(1); + const error = transportErrors[0]!; + expect(error).toMatchObject({ + deviceId: target.device_id, + kind: "live_activity_update", + sourceJobId: "job-1", + apnsErrorTag: "ApnsJwtSigningError", + requestStage: null, + }); + expect(error.cause).toBeInstanceOf(ApnsClient.ApnsJwtSigningError); + expect(error.cause).toMatchObject({ + teamId: "team-id", + keyId: "key-id", + }); + expect((error.cause as ApnsClient.ApnsJwtSigningError).cause).toBeDefined(); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeLayer({ attempts }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); }); it.effect("processes signed push notification jobs through APNs and records attempts", () => { diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 6c714d1d56d..c83eaf34f2e 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -7,12 +7,14 @@ import type { import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema, RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema, + RelayDeliveryKind as RelayDeliveryKindSchema, } from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { @@ -87,6 +89,28 @@ export class ApnsDeliveryJobClaimInFlight extends Schema.TaggedErrorClass()( + "ApnsDeliveryTransportError", + { + deviceId: Schema.String, + kind: RelayDeliveryKindSchema, + sourceJobId: Schema.NullOr(Schema.String), + apnsErrorTag: Schema.Literals([ + "ApnsJwtEncodingError", + "ApnsJwtSigningError", + "ApnsHttpRequestError", + ]), + requestStage: Schema.NullOr(Schema.Literals(["send", "read-response"])), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `APNs ${this.kind} delivery failed for device ${this.deviceId}.`; + } +} + +export const isApnsDeliveryTransportError = Schema.is(ApnsDeliveryTransportError); + const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), ); @@ -302,6 +326,42 @@ function deliveryAttemptOutcome(result: Apns.ApnsDeliveryResult) { }; } +const recoverApnsDeliveryTransportError = ( + input: { + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly sourceJobId: string | null; + }, + cause: Apns.ApnsError, +): Effect.Effect => { + const error = new ApnsDeliveryTransportError({ + deviceId: input.deviceId, + kind: input.kind, + sourceJobId: input.sourceJobId, + apnsErrorTag: cause._tag, + requestStage: cause._tag === "ApnsHttpRequestError" ? cause.stage : null, + cause, + }); + return Effect.logError(error.message).pipe( + Effect.annotateLogs({ + error: Redacted.make(error, { label: error._tag }), + "error.type": error._tag, + "error.apns_error_tag": error.apnsErrorTag, + ...(error.requestStage === null ? {} : { "error.request_stage": error.requestStage }), + ...(error.stack === undefined ? {} : { "error.stack": error.stack }), + "relay.mobile.device_id": error.deviceId, + "relay.delivery.kind": error.kind, + ...(error.sourceJobId === null ? {} : { "relay.delivery.job_id": error.sourceJobId }), + }), + Effect.as({ + ok: false, + status: 0, + reason: cause.message, + apnsId: null, + }), + ); +}; + interface LiveActivityDeliveryTarget { readonly user_id: string; readonly device_id: string; @@ -440,6 +500,15 @@ export const make = Effect.gen(function* () { { ...input, aggregate } as SendLiveActivityDeliveryInput, now, ); + const recoverTransportError = (cause: Apns.ApnsError) => + recoverApnsDeliveryTransportError( + { + deviceId: input.target.device_id, + kind: input.kind, + sourceJobId: input.sourceJobId ?? null, + }, + cause, + ); if (input.sourceJobId) { const claim = yield* attempts.claimSourceJob({ userId: input.target.user_id, @@ -476,14 +545,11 @@ export const make = Effect.gen(function* () { issuedAtUnixSeconds: epochSeconds, }) .pipe( - Effect.catch((error) => - Effect.succeed({ - ok: false, - status: 0, - reason: error.message, - apnsId: null, - }), - ), + Effect.catchTags({ + ApnsJwtEncodingError: recoverTransportError, + ApnsJwtSigningError: recoverTransportError, + ApnsHttpRequestError: recoverTransportError, + }), ); if (result.ok) { yield* liveActivities.markDelivery({ @@ -551,6 +617,15 @@ export const make = Effect.gen(function* () { token: input.token, notification, }); + const recoverTransportError = (cause: Apns.ApnsError) => + recoverApnsDeliveryTransportError( + { + deviceId: input.target.device_id, + kind: "push_notification", + sourceJobId: input.sourceJobId ?? null, + }, + cause, + ); if (input.sourceJobId) { const claim = yield* attempts.claimSourceJob({ userId: input.target.user_id, @@ -593,14 +668,11 @@ export const make = Effect.gen(function* () { issuedAtUnixSeconds: epochSeconds, }) .pipe( - Effect.catch((error) => - Effect.succeed({ - ok: false, - status: 0, - reason: error.message, - apnsId: null, - }), - ), + Effect.catchTags({ + ApnsJwtEncodingError: recoverTransportError, + ApnsJwtSigningError: recoverTransportError, + ApnsHttpRequestError: recoverTransportError, + }), ); if (isPermanentApnsTokenFailure(result)) { yield* liveActivities.invalidateDeliveryToken({ From 6155f5cf64e9f26d10f3c912985f8ca09f9c3fef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:04 -0700 Subject: [PATCH 234/257] [codex] Preserve VCS project config error causes (#3474) Co-authored-by: codex --- apps/server/src/vcs/VcsProjectConfig.test.ts | 35 +++++++++++--------- apps/server/src/vcs/VcsProjectConfig.ts | 15 +++++---- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 5fe5dcc7564..04f7fcffcda 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -96,16 +96,19 @@ describe("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, { + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal( + error.message, + "Failed to inspect VCS project config at " + failedCandidate + ".", + ); + assert.deepInclude(error, { operation: "inspect", cwd, configPath: failedCandidate, - errorTag: "VcsProjectConfigError", + _tag: "VcsProjectConfigError", }); - assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); @@ -146,18 +149,19 @@ describe("VcsProjectConfig", () => { const kind = yield* config.resolveKind({ cwd: root }); assert.equal(kind, "auto"); - const [message, context] = messages[0] as [string, Record]; + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); assert.equal( - message, + error.message, "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".", ); - assert.deepInclude(context, { + assert.deepInclude(error.cause, { _tag: "SchemaError" }); + assert.deepInclude(error, { operation: "decode", cwd: root, configPath: path.join(configDir, "vcs.json"), - errorTag: "VcsProjectConfigError", + _tag: "VcsProjectConfigError", }); - assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); @@ -182,15 +186,16 @@ 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 read VCS project config at " + configPath + "."); - assert.deepInclude(context, { + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal(error.message, "Failed to read VCS project config at " + configPath + "."); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + assert.deepInclude(error, { operation: "read", cwd: root, configPath, - errorTag: "VcsProjectConfigError", + _tag: "VcsProjectConfigError", }); - assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index bd8f4515007..6abce9a3ef3 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -55,13 +55,14 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto } const logVcsProjectConfigError = (error: VcsProjectConfigError) => - Effect.logWarning(error.message, { - operation: error.operation, - cwd: error.cwd, - configPath: error.configPath, - errorTag: error._tag, - stack: error.stack, - }); + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + operation: error.operation, + cwd: error.cwd, + configPath: error.configPath, + errorTag: error._tag, + }), + ); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; From 90074e359f360bd7983661757ccf995524b9dbaa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:07 -0700 Subject: [PATCH 235/257] [codex] Preserve desktop update state causes (#3473) Co-authored-by: codex --- apps/web/src/state/desktopUpdate.test.ts | 11 ++++++++--- apps/web/src/state/desktopUpdate.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index f6b3081a80f..a2bcbd19a33 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -3,7 +3,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AtomRegistry } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { createDesktopUpdateStateAtom } from "./desktopUpdate"; +import { createDesktopUpdateStateAtom, DesktopUpdateStateReadError } from "./desktopUpdate"; const baseState: DesktopUpdateState = { enabled: true, @@ -117,8 +117,13 @@ describe("desktopUpdateStateAtom", () => { errorTag: "DesktopUpdateStateReadError", attemptCount: 3, }); - expect(errorContext).not.toHaveProperty("error"); - expect(errorContext).not.toHaveProperty("cause"); + const loggedError = (errorContext as { readonly error: unknown }).error; + expect(loggedError).toBeInstanceOf(DesktopUpdateStateReadError); + expect(loggedError).toMatchObject({ + _tag: "DesktopUpdateStateReadError", + attemptCount: 3, + }); + expect((loggedError as DesktopUpdateStateReadError).cause).toBe(cause); listener?.(baseState); await vi.waitFor(() => { diff --git a/apps/web/src/state/desktopUpdate.ts b/apps/web/src/state/desktopUpdate.ts index 75764410625..a7b9ed483b6 100644 --- a/apps/web/src/state/desktopUpdate.ts +++ b/apps/web/src/state/desktopUpdate.ts @@ -59,9 +59,9 @@ export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridg Effect.catchTags({ DesktopUpdateStateReadError: (error) => Effect.logError(error.message, { + error, errorTag: error._tag, attemptCount: error.attemptCount, - stack: error.stack, }).pipe(Effect.as(null)), }), ); From 8c1605b38327176bae94c33bbc6de86e8b09fe02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:09 -0700 Subject: [PATCH 236/257] [codex] Structure OpenCode text generation failures (#3472) Co-authored-by: codex --- .../OpenCodeTextGeneration.test.ts | 145 +++++++-- .../textGeneration/OpenCodeTextGeneration.ts | 296 ++++++++++++++---- 2 files changed, 353 insertions(+), 88 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index f6d9c133f38..558a8663b64 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,4 +1,4 @@ -import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -11,8 +11,8 @@ import { beforeEach, expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; import * as TextGeneration from "./TextGeneration.ts"; -import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; const runtimeMock = { state: { @@ -20,8 +20,11 @@ const runtimeMock = { promptUrls: [] as string[], authHeaders: [] as Array, closeCalls: [] as string[], + sessionCreateError: undefined as unknown, + sessionResult: undefined as { data?: { id: string } } | undefined, + promptRequestError: undefined as unknown, promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | { data?: { info?: { error?: unknown }; parts?: Array } } | undefined, }, reset() { @@ -29,6 +32,9 @@ const runtimeMock = { this.state.promptUrls.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; + this.state.sessionCreateError = undefined; + this.state.sessionResult = undefined; + this.state.promptRequestError = undefined; this.state.promptResult = undefined; }, }; @@ -61,12 +67,20 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => ({ data: { id: `${baseUrl}/session` } }), + create: async () => { + if (runtimeMock.state.sessionCreateError !== undefined) { + throw runtimeMock.state.sessionCreateError; + } + return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; + }, prompt: async () => { runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); + if (runtimeMock.state.promptRequestError !== undefined) { + throw runtimeMock.state.promptRequestError; + } return ( runtimeMock.state.promptResult ?? { data: { @@ -99,6 +113,13 @@ const DEFAULT_TEST_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("opencode"), model: "openai/gpt-5", }; +const DEFAULT_COMMIT_MESSAGE_INPUT = { + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, +}; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; @@ -142,7 +163,7 @@ function withOpenCodeTextGeneration( effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const textGeneration = yield* makeOpenCodeTextGeneration(settings); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -221,22 +242,99 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); - it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => + it.effect("preserves the SDK cause when session creation fails", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { - runtimeMock.state.promptResult = { data: {} }; + const sdkCause = new Error("session endpoint unavailable"); + runtimeMock.state.sessionCreateError = sdkCause; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("OpenCode session.create request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("reports a missing session payload without manufacturing a cause", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = {}; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.create returned no session payload."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionPayloadError", + operation: "generateCommitMessage", + cwd: process.cwd(), + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + + it.effect("preserves the SDK cause and request context when prompting fails", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const sdkCause = new Error("prompt endpoint unavailable"); + runtimeMock.state.promptRequestError = sdkCause; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.prompt request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("returns a typed empty-output error for malformed and blank response parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [null, { type: "tool" }, { type: "text", text: " " }], + }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("OpenCode returned empty output."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationEmptyOutputError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + responsePartCount: 3, + textPartCount: 1, + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); @@ -289,16 +387,21 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { }; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("Model did not produce structured output"); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptResponseError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + providerErrorName: "StructuredOutputError", + providerMessage: "Model did not produce structured output", + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index f59e7694213..1f94f970692 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { + NonNegativeInt, TextGenerationError, type ChatAttachment, type ModelSelection, @@ -33,11 +34,106 @@ import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; -function getOpenCodePromptErrorMessage(error: unknown): string | null { +const OpenCodeTextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", +]); + +type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; + +const openCodeTextGenerationErrorContext = { + operation: OpenCodeTextGenerationOperation, + cwd: Schema.String, +}; + +export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionRequestError", + { + ...openCodeTextGenerationErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; + } +} + +export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionPayloadError", + openCodeTextGenerationErrorContext, +) { + override get message(): string { + return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; + } +} + +const openCodePromptErrorContext = { + ...openCodeTextGenerationErrorContext, + sessionId: Schema.String, + providerId: Schema.String, + modelId: Schema.String, +}; + +export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptRequestError", + { + ...openCodePromptErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; + } +} + +export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptResponseError", + { + ...openCodePromptErrorContext, + providerErrorName: Schema.optional(Schema.String), + providerMessage: Schema.String, + }, +) { + override get message(): string { + const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; + return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; + } +} + +export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationEmptyOutputError", + { + ...openCodePromptErrorContext, + responsePartCount: NonNegativeInt, + textPartCount: NonNegativeInt, + }, +) { + override get message(): string { + return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; + } +} + +interface OpenCodePromptFailure { + readonly name?: string; + readonly message: string; +} + +interface OpenCodeTextPart { + readonly type: "text"; + readonly text: string; +} + +function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { if (!error || typeof error !== "object") { return null; } + const name = + "name" in error && typeof error.name === "string" && error.name.trim().length > 0 + ? error.name.trim() + : undefined; const message = "data" in error && error.data && @@ -47,31 +143,34 @@ function getOpenCodePromptErrorMessage(error: unknown): string | null { ? error.data.message.trim() : ""; if (message.length > 0) { - return message; + return { + ...(name ? { name } : {}), + message, + }; } - if ("name" in error && typeof error.name === "string") { - const name = error.name.trim(); - return name.length > 0 ? name : null; + if (name) { + return { name, message: name }; } return null; } +function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { + return ( + part !== null && + typeof part === "object" && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ); +} + function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { return (parts ?? []) - .flatMap((part) => { - if (!part || typeof part !== "object") { - return []; - } - if (!("type" in part) || part.type !== "text") { - return []; - } - if (!("text" in part) || typeof part.text !== "string") { - return []; - } - return [part.text]; - }) + .filter(isOpenCodeTextPart) + .map((part) => part.text) .join("") .trim(); } @@ -260,11 +359,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ); const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; + readonly operation: OpenCodeTextGenerationOperation; readonly cwd: string; readonly prompt: string; readonly outputSchemaJson: S; @@ -285,54 +380,121 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); - const runAgainstServer = (server: Pick) => - Effect.tryPromise({ - try: async () => { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( + function* (server: Pick) { + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }); + const session = yield* Effect.tryPromise({ + try: () => + client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }), + catch: (cause) => + new OpenCodeTextGenerationSessionRequestError({ + operation: input.operation, + cwd: input.cwd, + cause, + }), + }); + if (!session.data) { + return yield* new OpenCodeTextGenerationSessionPayloadError({ + operation: input.operation, + cwd: input.cwd, }); - const session = await client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const promptContext = { + operation: input.operation, + cwd: input.cwd, + sessionId: session.data.id, + providerId: parsedModel.providerID, + modelId: parsedModel.modelID, + }; + + const result = yield* Effect.tryPromise({ + try: () => + client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }), + catch: (cause) => + new OpenCodeTextGenerationPromptRequestError({ + ...promptContext, + cause, + }), + }); + const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); + if (promptFailure) { + return yield* new OpenCodeTextGenerationPromptResponseError({ + ...promptContext, + ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), + providerMessage: promptFailure.message, }); - if (!session.data) { - throw new Error("OpenCode session.create returned no session payload."); - } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue( - input.modelSelection, - "variant", - ); - - const result = await client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], + } + const responseParts = result.data?.parts ?? []; + const rawText = getOpenCodeTextResponse(responseParts); + if (rawText.length === 0) { + return yield* new OpenCodeTextGenerationEmptyOutputError({ + ...promptContext, + responsePartCount: responseParts.length, + textPartCount: responseParts.filter(isOpenCodeTextPart).length, }); - const info = result.data?.info; - const errorMessage = getOpenCodePromptErrorMessage(info?.error); - if (errorMessage) { - throw new Error(errorMessage); - } - const rawText = getOpenCodeTextResponse(result.data?.parts); - if (rawText.length === 0) { - throw new Error("OpenCode returned empty output."); - } - return rawText; - }, - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), - }); + } + return rawText; + }, + Effect.catchTags({ + OpenCodeTextGenerationSessionRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create request failed.", + cause, + }), + ), + OpenCodeTextGenerationSessionPayloadError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create returned no session payload.", + cause, + }), + ), + OpenCodeTextGenerationPromptRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.prompt request failed.", + cause, + }), + ), + OpenCodeTextGenerationPromptResponseError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: cause.providerMessage, + cause, + }), + ), + OpenCodeTextGenerationEmptyOutputError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode returned empty output.", + cause, + }), + ), + }), + ); const rawOutput = openCodeSettings.serverUrl.length > 0 From 2a29de7502cdd02686bda7d12fb2671060fd1979 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:12 -0700 Subject: [PATCH 237/257] [codex] Structure primary auth validation failures (#3471) Co-authored-by: codex --- apps/web/src/authBootstrap.test.ts | 102 ++++++++++++---- apps/web/src/environments/primary/auth.ts | 122 +++++++++++-------- apps/web/src/environments/primary/context.ts | 1 - apps/web/src/environments/primary/index.ts | 2 + 4 files changed, 146 insertions(+), 81 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index c0713bfc059..ced16c15f4e 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -71,6 +71,18 @@ function installTestBrowser(url: string) { return testWindow; } +function installDesktopBootstrap() { + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; +} + function sequence(...values: ReadonlyArray) { let index = 0; return () => values[Math.min(index++, values.length - 1)]!; @@ -131,15 +143,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -289,6 +293,23 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("rejects a blank pairing token with a structured validation error", async () => { + const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const error = await submitServerAuthCredential(" ").then( + () => null, + (failure: unknown) => failure, + ); + + expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRequiredError", + providedLength: 3, + message: "Enter a pairing token to continue.", + }); + }); + it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { const cause = new EnvironmentAuthInvalidError({ code: "auth_invalid", @@ -299,7 +320,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.fail(cause), }); - const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } = + const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } = await import("./environments/primary"); const error = await submitServerAuthCredential("bad-token").then( @@ -307,14 +328,13 @@ describe("resolveInitialServerAuthGateState", () => { (failure: unknown) => failure, ); expect(error).toMatchObject({ - _tag: "PrimaryEnvironmentRequestError", - operation: "exchange-bootstrap-credential", - status: 401, - detail: "Invalid pairing token. Check the token and try again.", + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + providedLength: 9, + message: "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(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true); + if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) { + throw new Error("Expected a structured rejected pairing credential error."); } expect(error.cause).toMatchObject({ _tag: "EnvironmentAuthInvalidError", @@ -325,6 +345,22 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); + it("derives primary request messages from structural request context", async () => { + const cause = new Error("private transport detail"); + const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); + const error = PrimaryEnvironmentRequestError.fromCause({ + operation: "list-pairing-links", + cause, + }); + + expect(error.status).toBe(500); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Primary environment request failed during list-pairing-links (HTTP 500).", + ); + expect(error.message).not.toContain(cause.message); + }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { vi.useFakeTimers(); const nextSession = sequence( @@ -337,15 +373,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -356,6 +384,28 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(3); }); + it("preserves the timeout message when a bootstrapped session never becomes observable", async () => { + vi.useFakeTimers(); + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + + installDesktopBootstrap(); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Timed out waiting for authenticated session after bootstrap.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("memoizes the authenticated gate state after the first successful read", async () => { const testApi = await installAuthApi({ session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 5cf7d2d34b7..96814b92b79 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -40,7 +40,6 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass 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, @@ -67,11 +62,59 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRejectedError", + { + providedLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid pairing token. Check the token and try again."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is( + PrimaryEnvironmentPairingCredentialRejectedError, +); + +export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentAuthSessionTimeoutError", + { + timeoutMs: Schema.Number, + elapsedMs: Schema.Number, + }, +) { + override get message(): string { + return "Timed out waiting for authenticated session after bootstrap."; + } +} + +export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is( + PrimaryEnvironmentAuthSessionTimeoutError, +); + +export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRequiredError", + { + providedLength: Schema.Number, + }, +) { + override get message(): string { + return "Enter a pairing token to continue."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is( + PrimaryEnvironmentPairingCredentialRequiredError, +); + const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -151,7 +194,6 @@ export async function fetchSessionState(): Promise { throw PrimaryEnvironmentRequestError.fromCause({ operation: "fetch-session-state", cause: error, - fallbackMessage: (status) => `Failed to load server auth session state (${status}).`, }); } }); @@ -180,42 +222,6 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): } } -function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string { - if (!isEnvironmentHttpCommonError(error)) { - return fallbackMessage; - } - switch (error._tag) { - case "EnvironmentAuthInvalidError": - return error.reason === "missing_credential" - ? "Authentication required." - : "Invalid bootstrap credential."; - case "EnvironmentRequestInvalidError": - return error.reason === "invalid_scope" - ? "Requested token scope is invalid." - : "Requested scope exceeds the bootstrap credential grant."; - case "EnvironmentScopeRequiredError": - return `The authenticated token is missing required scope: ${error.requiredScope}.`; - case "EnvironmentOperationForbiddenError": - return "This operation is not allowed for the current session."; - case "EnvironmentInternalError": - return fallbackMessage; - } -} - -const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([ - "Invalid bootstrap credential.", - "Unknown bootstrap credential.", -]); - -function toFriendlyBootstrapErrorMessage(status: number, message: string): string { - const trimmedMessage = message.trim(); - if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) { - return "Invalid pairing token. Check the token and try again."; - } - - return trimmedMessage; -} - async function exchangeBootstrapCredential(credential: string): Promise { return retryTransientBootstrap(async () => { try { @@ -225,11 +231,19 @@ async function exchangeBootstrapCredential(credential: string): Promise `Failed to bootstrap auth session (${status}).`, - formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail), }); } }); @@ -244,8 +258,12 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { - throw new Error("Timed out waiting for authenticated session after bootstrap."); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { + throw new PrimaryEnvironmentAuthSessionTimeoutError({ + timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS, + elapsedMs, + }); } await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); @@ -323,7 +341,9 @@ async function bootstrapServerAuth(): Promise { export async function submitServerAuthCredential(credential: string): Promise { const trimmedCredential = credential.trim(); if (!trimmedCredential) { - throw new Error("Enter a pairing token to continue."); + throw new PrimaryEnvironmentPairingCredentialRequiredError({ + providedLength: credential.length, + }); } resolvedAuthenticatedGateState = null; @@ -355,7 +375,6 @@ export async function createServerPairingCredential(input?: { throw PrimaryEnvironmentRequestError.fromCause({ operation: "create-pairing-credential", cause: error, - fallbackMessage: (status) => `Failed to create pairing credential (${status}).`, }); } } @@ -396,7 +415,6 @@ export async function listServerPairingLinks(): Promise `Failed to load pairing links (${status}).`, }); } } @@ -413,7 +431,6 @@ export async function revokeServerPairingLink(id: string): Promise { operation: "revoke-pairing-link", pairingLinkId: id, cause: error, - fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`, }); } } @@ -446,7 +463,6 @@ export async function listServerClientSessions(): Promise< throw PrimaryEnvironmentRequestError.fromCause({ operation: "list-client-sessions", cause: error, - fallbackMessage: (status) => `Failed to load paired clients (${status}).`, }); } } @@ -465,7 +481,6 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi operation: "revoke-client-session", sessionId, cause: error, - fallbackMessage: (status) => `Failed to revoke client session (${status}).`, }); } } @@ -482,7 +497,6 @@ export async function revokeOtherServerClientSessions(): Promise { 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 40b6f68bd09..e1021a7feb4 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -46,7 +46,6 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise `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 e888560539d..58342d53054 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -16,10 +16,12 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentPairingCredentialRejectedError, isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentPairingCredentialRejectedError, PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, From 0debebafbb7d98a3c77d3e636fa54616a98d26da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:41:58 -0700 Subject: [PATCH 238/257] [codex] Structure thread archive blocked error (#3451) --- apps/web/src/hooks/useThreadActions.test.ts | 19 +++++++++++++++++++ apps/web/src/hooks/useThreadActions.ts | 21 +++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/hooks/useThreadActions.test.ts diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts new file mode 100644 index 00000000000..c5385211591 --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -0,0 +1,19 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadArchiveBlockedError } from "./useThreadActions"; + +describe("ThreadArchiveBlockedError", () => { + it("keeps the blocked thread context with the fixed message", () => { + const error = new ThreadArchiveBlockedError({ + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }); + + expect(error).toMatchObject({ + environmentId: "environment-1", + threadId: "thread-1", + }); + expect(error.message).toBe("Cannot archive a running thread."); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 35783348068..07655ad30d7 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,9 +4,9 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; 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 { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; @@ -27,9 +27,17 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; -export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{ - readonly message: string; -}> {} +export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadArchiveBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "Cannot archive a running thread."; + } +} export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); @@ -89,7 +97,8 @@ export function useThreadActions() { return AsyncResult.failure( Cause.fail( new ThreadArchiveBlockedError({ - message: "Cannot archive a running thread.", + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, }), ), ); From 61f8d46fa2300e63c57229a6867c89b26c2f8f7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:43:41 -0700 Subject: [PATCH 239/257] [codex] Enforce Effect error handling conventions (#3380) --- .../check-run-agents/effect-service-conventions.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index d474c41d2fe..afbdc55ba60 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -48,7 +48,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 a generic error: its tag, fields, or both must identify the failure structurally, and its `message` must not merely stringify an opaque cause. A semantically precise error tag may preserve a real `cause` without inventing a redundant singleton field when no additional variable context exists; still retain any real path, resource, request, or entity context available at the wrapping site. - Capture stable, serializable domain context such as the operation or stage, resource/path or entity identifier, and normalized category/status. Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error. Do not add a `detail` field that merely copies `cause.message` and then use it to construct the wrapper message. +- Keep direct error attributes and log annotations safe and bounded. Do not copy raw wire payloads, command arguments or output, signed URLs, credentials, query strings, fragments, selectors, or arbitrary defect text into `detail`, `reason`, `message`, or a parallel log payload. Preserve the exact underlying value only as `cause`; expose normalized categories plus lengths/counts and safe URL protocol/hostname diagnostics where useful. Logging a sanitized error must not reintroduce a removed legacy `detail` or serialized `cause` field beside it. - When translating or wrapping a real failure, preserve the immediate underlying error itself as `cause` alongside the structural fields so the complete error chain and stack remain available. If every construction wraps a failure, `cause` should be required; make it optional only when the same error can legitimately originate without an underlying failure. +- At a translation boundary, pass through an already structured domain error when it is part of the declared target error channel. Wrap only unknown or genuinely lower-level failures. A static factory or mapper may perform this classification when it is reused and keeps the policy next to the target error type. - Derive the wrapper's `message` exclusively from its stable structural attributes, never from `cause`, `cause.message`, or a stringified defect. Do not replace the immediate error with only `error.cause`, erase a structured upstream error into a string, or manufacture an `Error` merely to populate `cause`. Pure validation/domain errors created without an underlying failure do not need a cause. - Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Choose one coherent model: use distinct error classes and omit the redundant discriminator when callers or messages treat the failures as genuinely different, or use one service-level error with a multi-value operation discriminator and a generic message derived from that operation when the failures share the same semantics. - Treat an error message exposed through an HTTP/RPC response, persisted state, UI, or another caller-visible boundary as behavior. Preserve those messages during a structural refactor. Existing distinct caller-visible messages are evidence that the failures should normally remain distinct error tags without redundant singleton discriminators, rather than being collapsed into a generic operation error. @@ -56,6 +58,9 @@ Review changed TypeScript and directly affected call sites for the conventions b - Use `Schema.Union` of error classes when a shared schema, predicate, or helper type is useful. - Export direct schema predicates such as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a redundant function with the same signature. - Do not introduce a large `switch` or lookup table in an error's `message` getter to model failures that deserve separate error classes. +- Catch statically known tagged failures with `Effect.catchTags({ ... })`, including when handling only one tag. Do not use `catchIf` with a schema predicate merely to recover one or more known `_tag` variants, and do not use `catchTag`. `Effect.catch` is appropriate when the entire error channel is intentionally handled; `catchIf` remains appropriate for genuinely structural predicates such as inspecting an underlying platform error code. +- Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`, including curried aliases used once with `mapError`. Construct the error at the failure boundary so its attributes and cause remain visible. Keep a mapper only when it performs real normalization, passes through existing domain errors, or adds reusable context/control flow. +- When a reusable error-to-error translation clearly belongs to the target error type, prefer a descriptive static factory on that error class over a detached production-side switch. Do not force a static method for one-off inline mappings. ## File layout and migrations @@ -73,4 +78,6 @@ Review changed TypeScript and directly affected call sites for the conventions b ## 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`. +Report only concrete violations introduced or retained in the pull request's changed scope. Prefer precise inline comments on the smallest relevant line range and state the expected fix. A clear convention violation may fail the check. Do not fail for optional style preferences or unrelated legacy code. + +This check defaults to failure. When there are no findings, stop immediately and make the entire final response exactly `All clear` on one line. Do not add a title, explanation, punctuation, Markdown, JSON, or trailing analysis, and do not continue reasoning after deciding the review is clean. From 82a9bcc7f4e1c96bfb9cdc5acdd5be67151c707c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:45:17 -0700 Subject: [PATCH 240/257] [codex] add session context to credential errors (#3349) --- apps/server/src/auth/SessionStore.test.ts | 84 ++++- apps/server/src/auth/SessionStore.ts | 369 +++++++++++++--------- 2 files changed, 304 insertions(+), 149 deletions(-) diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 0dd5d797d19..334c24ef52f 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -44,8 +44,8 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi create: () => Effect.void, getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), - revoke: () => Effect.succeed(false), - revokeAllExcept: () => Effect.succeed([]), + revoke: () => Effect.fail(repositoryFailure), + revokeAllExcept: () => Effect.fail(repositoryFailure), setLastConnectedAt: () => Effect.void, }); @@ -104,11 +104,29 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessionError = yield* Effect.flip(sessions.verify(issued.token)); const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId)); + const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId)); expect(sessionError._tag).toBe("SessionCredentialVerificationError"); expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); expect(sessionError.cause).toBe(repositoryFailure); expect(websocketError.cause).toBe(repositoryFailure); + if (sessionError._tag === "SessionCredentialVerificationError") { + expect(sessionError.sessionId).toBe(issued.sessionId); + } + if (websocketError._tag === "WebSocketTokenVerificationError") { + expect(websocketError.sessionId).toBe(issued.sessionId); + } + expect(revokeError).toMatchObject({ + _tag: "SessionRevocationError", + sessionId: issued.sessionId, + cause: repositoryFailure, + }); + expect(revokeOthersError).toMatchObject({ + _tag: "OtherSessionsRevocationError", + currentSessionId: issued.sessionId, + cause: repositoryFailure, + }); }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), ); it.effect("verifies session tokens against the Effect clock", () => @@ -145,7 +163,52 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { yield* TestClock.adjust(Duration.seconds(2)); const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); - expect(error.message).toContain("expired"); + expect(error._tag).toBe("WebSocketSessionExpiredError"); + if (error._tag === "WebSocketSessionExpiredError") { + expect(error.sessionId).toBe(issued.sessionId); + expect(error.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); + expect(error.observedAt.epochMilliseconds).toBeGreaterThan( + error.expiresAt.epochMilliseconds, + ); + } + }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), + ); + + it.effect("includes expiry context when session and websocket tokens expire", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const issued = yield* sessions.issue({ + method: "bearer-access-token", + subject: "short-lived-token", + ttl: Duration.seconds(1), + }); + const websocket = yield* sessions.issueWebSocketToken(issued.sessionId, { + ttl: Duration.seconds(1), + }); + + yield* TestClock.adjust(Duration.seconds(2)); + + const sessionError = yield* Effect.flip(sessions.verify(issued.token)); + const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + + expect(sessionError._tag).toBe("SessionTokenExpiredError"); + if (sessionError._tag === "SessionTokenExpiredError") { + expect(sessionError.sessionId).toBe(issued.sessionId); + expect(sessionError.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); + expect(sessionError.observedAt.epochMilliseconds).toBeGreaterThan( + sessionError.expiresAt.epochMilliseconds, + ); + } + expect(websocketError._tag).toBe("WebSocketTokenExpiredError"); + if (websocketError._tag === "WebSocketTokenExpiredError") { + expect(websocketError.sessionId).toBe(issued.sessionId); + expect(websocketError.expiresAt.epochMilliseconds).toBe( + websocket.expiresAt.epochMilliseconds, + ); + expect(websocketError.observedAt.epochMilliseconds).toBeGreaterThan( + websocketError.expiresAt.epochMilliseconds, + ); + } }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); @@ -173,12 +236,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { ipAddress: "192.168.1.88", }, }); + const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId); yield* sessions.markConnected(client.sessionId); const beforeRevoke = yield* sessions.listActive(); const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId); const afterRevoke = yield* sessions.listActive(); const revokedClient = yield* Effect.flip(sessions.verify(client.token)); + const revokedClientWebSocket = yield* Effect.flip( + sessions.verifyWebSocketToken(clientWebSocket.token), + ); expect(beforeRevoke).toHaveLength(2); expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( @@ -194,7 +261,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(revokedCount).toBe(1); expect(afterRevoke).toHaveLength(1); expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId); - expect(revokedClient.message).toContain("revoked"); + expect(revokedClient._tag).toBe("SessionTokenRevokedError"); + if (revokedClient._tag === "SessionTokenRevokedError") { + expect(revokedClient.sessionId).toBe(client.sessionId); + expect(revokedClient.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0); + } + expect(revokedClientWebSocket._tag).toBe("WebSocketSessionRevokedError"); + if (revokedClientWebSocket._tag === "WebSocketSessionRevokedError") { + expect(revokedClientWebSocket.sessionId).toBe(client.sessionId); + expect(revokedClientWebSocket.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0); + } }).pipe(Effect.provide(makeSessionStoreLayer())), ); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 18008a7d0a1..12ecb7dba4d 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -7,7 +7,6 @@ import { type AuthEnvironmentScope, type ServerAuthSessionMethod, } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass()( "SessionTokenExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Session token expired."; @@ -102,7 +105,9 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass()( "UnknownSessionTokenError", - {}, + { + sessionId: AuthSessionId, + }, ) { override get message(): string { return "Unknown session token."; @@ -111,7 +116,10 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( "SessionTokenRevokedError", - {}, + { + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Session token revoked."; @@ -120,7 +128,10 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( "InvalidSessionExpirationClaimError", - {}, + { + sessionId: AuthSessionId, + expirationClaim: Schema.Number, + }, ) { override get message(): string { return "Invalid `exp` claim"; @@ -158,7 +169,11 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( "WebSocketTokenExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket token expired."; @@ -167,7 +182,9 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass()( "UnknownWebSocketSessionError", - {}, + { + sessionId: AuthSessionId, + }, ) { override get message(): string { return "Unknown websocket session."; @@ -176,7 +193,11 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( "WebSocketSessionExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket session expired."; @@ -185,7 +206,10 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( "WebSocketSessionRevokedError", - {}, + { + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket session revoked."; @@ -218,6 +242,7 @@ const sessionCredentialInternalErrorContext = { export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( "SessionClaimsEncodingError", { + sessionId: AuthSessionId, operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), ...sessionCredentialInternalErrorContext, }, @@ -230,6 +255,7 @@ export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( "SessionCredentialIssueError", { + sessionId: Schema.optional(AuthSessionId), ...sessionCredentialInternalErrorContext, }, ) { @@ -241,6 +267,7 @@ export class SessionCredentialIssueError extends Schema.TaggedErrorClass()( "SessionCredentialVerificationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -252,6 +279,7 @@ export class SessionCredentialVerificationError extends Schema.TaggedErrorClass< export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( "WebSocketTokenIssueError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -263,6 +291,7 @@ export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( "WebSocketTokenVerificationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -285,6 +314,7 @@ export class ActiveSessionsListError extends Schema.TaggedErrorClass()( "SessionRevocationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -296,6 +326,7 @@ export class SessionRevocationError extends Schema.TaggedErrorClass()( "OtherSessionsRevocationError", { + currentSessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -539,7 +570,11 @@ export const make = Effect.gen(function* () { const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( function* (input) { - const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); + const sessionId = AuthSessionId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), + ), + ); const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), @@ -559,27 +594,37 @@ export const make = Effect.gen(function* () { const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( - (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), + (cause) => + new SessionCredentialIssueError({ + sessionId, + cause: new SessionClaimsEncodingError({ + sessionId, + operation: "encode_session_claims", + cause, + }), + }), ), ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); - yield* authSessions.create({ - sessionId, - subject: claims.sub, - scopes: claims.scopes, - method: claims.method, - client: { - label: client.label ?? null, - ipAddress: client.ipAddress ?? null, - userAgent: client.userAgent ?? null, - deviceType: client.deviceType, - os: client.os ?? null, - browser: client.browser ?? null, - }, - issuedAt, - expiresAt, - }); + yield* authSessions + .create({ + sessionId, + subject: claims.sub, + scopes: claims.scopes, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + }) + .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); yield* emitUpsert( toAuthClientSession({ sessionId, @@ -604,7 +649,6 @@ export const make = Effect.gen(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), ); const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( @@ -623,22 +667,37 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), ); - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new SessionTokenExpiredError({}); + const observedAt = yield* DateTime.now; + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new InvalidSessionExpirationClaimError({ + sessionId: claims.sid, + expirationClaim: claims.exp, + }); + } + if (claims.exp <= observedAt.epochMilliseconds) { + return yield* new SessionTokenExpiredError({ + sessionId: claims.sid, + expiresAt: expiresAt.value, + observedAt, + }); } - const row = yield* authSessions.getById({ sessionId: claims.sid }); + const row = yield* authSessions + .getById({ sessionId: claims.sid }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }), + ), + ); if (Option.isNone(row)) { - return yield* new UnknownSessionTokenError({}); + return yield* new UnknownSessionTokenError({ sessionId: claims.sid }); } if (row.value.revokedAt !== null) { - return yield* new SessionTokenRevokedError({}); - } - - const expiresAt = DateTime.make(claims.exp); - if (Option.isNone(expiresAt)) { - return yield* new InvalidSessionExpirationClaimError({}); + return yield* new SessionTokenRevokedError({ + sessionId: claims.sid, + revokedAt: row.value.revokedAt, + }); } return { @@ -652,95 +711,111 @@ export const make = Effect.gen(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies VerifiedSession; }, - Effect.mapError((cause) => - isSessionCredentialInvalidError(cause) - ? cause - : new SessionCredentialVerificationError({ cause }), - ), ); const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( "SessionStore.issueWebSocketToken", - )( - function* (sessionId, input) { - const issuedAt = yield* DateTime.now; - const expiresAt = DateTime.add(issuedAt, { - milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), - }); - const claims: WebSocketClaims = { - v: 1, - kind: "websocket", - sid: sessionId, - iat: issuedAt.epochMilliseconds, - exp: expiresAt.epochMilliseconds, - }; - const encodedPayload = yield* encodeWsClaims(claims).pipe( - Effect.map(base64UrlEncode), - Effect.mapError( - (cause) => - new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), - ), - ); - const signature = signPayload(encodedPayload, signingSecret); - return { - token: `${encodedPayload}.${signature}`, - expiresAt, - }; - }, - Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), - ); + )(function* (sessionId, input) { + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), + }); + const claims: WebSocketClaims = { + v: 1, + kind: "websocket", + sid: sessionId, + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = yield* encodeWsClaims(claims).pipe( + Effect.map(base64UrlEncode), + Effect.mapError( + (cause) => + new WebSocketTokenIssueError({ + sessionId, + cause: new SessionClaimsEncodingError({ + sessionId, + operation: "encode_websocket_claims", + cause, + }), + }), + ), + ); + const signature = signPayload(encodedPayload, signingSecret); + return { + token: `${encodedPayload}.${signature}`, + expiresAt, + }; + }); const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( "SessionStore.verifyWebSocketToken", - )( - function* (token) { - const [encodedPayload, signature] = token.split("."); - if (!encodedPayload || !signature) { - return yield* new MalformedWebSocketTokenError({}); - } - - const expectedSignature = signPayload(encodedPayload, signingSecret); - if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new InvalidWebSocketTokenSignatureError({}); - } + )(function* (token) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new MalformedWebSocketTokenError({}); + } - const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), - ); + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new InvalidWebSocketTokenSignatureError({}); + } - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new WebSocketTokenExpiredError({}); - } + const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), + ); - const row = yield* authSessions.getById({ sessionId: claims.sid }); - if (Option.isNone(row)) { - return yield* new UnknownWebSocketSessionError({}); - } - if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* new WebSocketSessionExpiredError({}); - } - if (row.value.revokedAt !== null) { - return yield* new WebSocketSessionRevokedError({}); - } + const observedAt = yield* DateTime.now; + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new InvalidSessionExpirationClaimError({ + sessionId: claims.sid, + expirationClaim: claims.exp, + }); + } + if (claims.exp <= observedAt.epochMilliseconds) { + return yield* new WebSocketTokenExpiredError({ + sessionId: claims.sid, + expiresAt: expiresAt.value, + observedAt, + }); + } - return { - sessionId: row.value.sessionId, - token, - method: row.value.method, - client: toClientMetadata(row.value.client), + const row = yield* authSessions + .getById({ sessionId: claims.sid }) + .pipe( + Effect.mapError( + (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }), + ), + ); + if (Option.isNone(row)) { + return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); + } + if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { + return yield* new WebSocketSessionExpiredError({ + sessionId: claims.sid, expiresAt: row.value.expiresAt, - subject: row.value.subject, - scopes: row.value.scopes, - } satisfies VerifiedSession; - }, - Effect.mapError((cause) => - isSessionCredentialInvalidError(cause) - ? cause - : new WebSocketTokenVerificationError({ cause }), - ), - ); + observedAt, + }); + } + if (row.value.revokedAt !== null) { + return yield* new WebSocketSessionRevokedError({ + sessionId: claims.sid, + revokedAt: row.value.revokedAt, + }); + } + + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + scopes: row.value.scopes, + } satisfies VerifiedSession; + }); const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( function* () { @@ -768,10 +843,12 @@ export const make = Effect.gen(function* () { const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( function* (sessionId) { const revokedAt = yield* DateTime.now; - const revoked = yield* authSessions.revoke({ - sessionId, - revokedAt, - }); + const revoked = yield* authSessions + .revoke({ + sessionId, + revokedAt, + }) + .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); if (revoked) { yield* Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -782,39 +859,41 @@ export const make = Effect.gen(function* () { } return revoked; }, - Effect.mapError((cause) => new SessionRevocationError({ cause })), ); const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", - )( - function* (sessionId) { - const revokedAt = yield* DateTime.now; - const revokedSessionIds = yield* authSessions.revokeAllExcept({ + )(function* (sessionId) { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions + .revokeAllExcept({ currentSessionId: sessionId, revokedAt, + }) + .pipe( + Effect.mapError( + (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), + ), + ); + if (revokedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) { + next.delete(revokedSessionId); + } + return next; }); - if (revokedSessionIds.length > 0) { - yield* Ref.update(connectedSessionsRef, (current) => { - const next = new Map(current); - for (const revokedSessionId of revokedSessionIds) { - next.delete(revokedSessionId); - } - return next; - }); - yield* Effect.forEach( - revokedSessionIds, - (revokedSessionId) => emitRemoved(revokedSessionId), - { - concurrency: "unbounded", - discard: true, - }, - ); - } - return revokedSessionIds.length; - }, - Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), - ); + yield* Effect.forEach( + revokedSessionIds, + (revokedSessionId) => emitRemoved(revokedSessionId), + { + concurrency: "unbounded", + discard: true, + }, + ); + } + return revokedSessionIds.length; + }); return SessionStore.of({ cookieName, From 28107e89c25711a835369c2b52c552f82e8de02a Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Mon, 22 Jun 2026 05:31:26 +0200 Subject: [PATCH 241/257] chore: remove `AnnotatableFileDiff` leftovers, rename file (#3488) --- apps/web/src/components/DiffPanel.tsx | 2 +- ...leFileDiff.tsx => AnnotatableCodeView.tsx} | 180 +----------------- 2 files changed, 2 insertions(+), 180 deletions(-) rename apps/web/src/components/diffs/{AnnotatableFileDiff.tsx => AnnotatableCodeView.tsx} (60%) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f39af581d5a..e41ba9c2440 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -38,7 +38,7 @@ import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff"; +import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx similarity index 60% rename from apps/web/src/components/diffs/AnnotatableFileDiff.tsx rename to apps/web/src/components/diffs/AnnotatableCodeView.tsx index f74b1e59aa3..6cea64fb570 100644 --- a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -6,13 +6,7 @@ import type { FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; -import { - CodeView, - type CodeViewHandle, - type CodeViewProps, - FileDiff, - type FileDiffProps, -} from "@pierre/diffs/react"; +import { CodeView, type CodeViewHandle, type CodeViewProps } from "@pierre/diffs/react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; @@ -76,178 +70,6 @@ function appendAnnotationEntry( ); } -interface AnnotatableFileDiffProps { - fileDiff: FileDiffMetadata; - filePath: string; - sectionId: string; - sectionTitle: string; - composerDraftTarget: ScopedThreadRef | DraftId; - options: FileDiffProps["options"]; - renderHeaderPrefix: (fileDiff: FileDiffMetadata) => ReactNode; -} - -export function AnnotatableFileDiff({ - fileDiff, - filePath, - sectionId, - sectionTitle, - composerDraftTarget, - options, - renderHeaderPrefix, -}: AnnotatableFileDiffProps) { - const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); - const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); - const reviewComments = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, - ); - const [selectedRange, setSelectedRange] = useState(null); - const [draftAnnotation, setDraftAnnotation] = useState(null); - const persistedAnnotations = useMemo( - () => - reviewComments - .filter( - (comment) => - comment.sectionId === sectionId && - comment.filePath === filePath && - (comment.fenceLanguage ?? "diff") === "diff", - ) - .reduce((annotations, comment) => { - const range = restoreDiffReviewCommentRange(fileDiff, comment); - if (!range) return annotations; - return appendAnnotationEntry(annotations, range, { - id: comment.id, - kind: "comment", - range, - rangeLabel: comment.rangeLabel, - text: comment.text, - }); - }, []), - [fileDiff, filePath, reviewComments, sectionId], - ); - const lineAnnotations = useMemo( - () => (draftAnnotation ? [...persistedAnnotations, draftAnnotation] : persistedAnnotations), - [draftAnnotation, persistedAnnotations], - ); - - const removeAnnotationEntry = useCallback( - (entryId: string) => { - setSelectedRange(null); - if ( - draftAnnotation?.metadata.entries.some( - (entry) => entry.id === entryId && entry.kind === "draft", - ) - ) { - setDraftAnnotation(null); - return; - } - removeReviewComment(composerDraftTarget, entryId); - }, - [composerDraftTarget, draftAnnotation, removeReviewComment], - ); - - const submitAnnotationEntry = useCallback( - (entryId: string, text: string) => { - const entry = draftAnnotation?.metadata.entries.find((candidate) => candidate.id === entryId); - if (!entry) return; - - const comment = buildDiffReviewComment({ - id: entry.id, - sectionId, - sectionTitle, - filePath, - fileDiff, - range: entry.range, - text, - }); - if (comment) { - addReviewComment(composerDraftTarget, comment); - } - setSelectedRange(null); - setDraftAnnotation(null); - }, - [ - addReviewComment, - composerDraftTarget, - fileDiff, - filePath, - draftAnnotation, - sectionId, - sectionTitle, - ], - ); - - const beginComment = useCallback( - (range: SelectedLineRange) => { - const id = nextFileCommentId(); - const comment = buildDiffReviewComment({ - id, - sectionId, - sectionTitle, - filePath, - fileDiff, - range, - text: "", - }); - if (!comment) return; - - const draftEntry: DiffCommentAnnotationEntry = { - id, - kind: "draft", - range, - rangeLabel: comment.rangeLabel, - text: "", - }; - setDraftAnnotation({ - side: annotationSide(range), - lineNumber: range.end, - metadata: { entries: [draftEntry] }, - }); - }, - [fileDiff, filePath, sectionId, sectionTitle], - ); - - const hasOpenCommentForm = draftAnnotation !== null; - const handleLineSelectionEnd = useCallback( - (range: SelectedLineRange | null) => { - setSelectedRange(range); - if (range) beginComment(range); - }, - [beginComment], - ); - - return ( - - fileDiff={fileDiff} - renderHeaderPrefix={renderHeaderPrefix} - options={{ - ...options, - enableGutterUtility: !hasOpenCommentForm, - enableLineSelection: !hasOpenCommentForm, - onGutterUtilityClick: setSelectedRange, - onLineSelectionChange: setSelectedRange, - onLineSelectionEnd: handleLineSelectionEnd, - }} - selectedLines={selectedRange} - lineAnnotations={lineAnnotations} - renderAnnotation={(annotation) => ( -
- {annotation.metadata.entries.map((entry) => ( - removeAnnotationEntry(entry.id)} - onComment={(text) => submitAnnotationEntry(entry.id, text)} - onDelete={() => removeAnnotationEntry(entry.id)} - /> - ))} -
- )} - /> - ); -} - interface AnnotatableCodeViewProps { files: ReadonlyArray<{ fileDiff: FileDiffMetadata; From ea52bb1dbda115f9824415f7589505c9e57268c6 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:46:15 +0200 Subject: [PATCH 242/257] [codex] fix: guard trace ID clipboard copy (#3505) Co-authored-by: Codex --- .../settings/ConnectionsSettings.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 96d9dd4510f..e693c7b15b0 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1423,6 +1423,31 @@ function SavedBackendListRow({ : "bg-muted-foreground/40"; const statusTooltip = connectionStatusText(environment.connection); const errorTraceId = environment.connection.traceId; + const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ + target: "trace ID", + onCopy: ({ traceId }) => { + toastManager.add({ + type: "success", + title: "Trace ID copied", + description: traceId, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy trace ID", + description: error.message, + }), + ); + }, + }); + const copyTraceId = useCallback( + (traceId: string) => { + copyTraceIdToClipboard(traceId, { traceId }); + }, + [copyTraceIdToClipboard], + ); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); const sshTarget = environment.entry.target._tag === "SshConnectionTarget" && @@ -1468,7 +1493,7 @@ function SavedBackendListRow({ From 8b993bc9ff79c52be691f56f077abdac26f0e69f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:10:47 +0200 Subject: [PATCH 243/257] [codex] fix: restore pending input keyboard activation (#3501) Co-authored-by: Codex --- .../components/chat/ComposerPendingUserInputPanel.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index bf869d25c66..d826eca0063 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -208,19 +208,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ); return ( -
{ - if (isResponding) return; handleOptionSelection(activeQuestion.id, option.label); }} className={className} > {content} -
+ ); })}
From c1e2408e1f317bba2fbb48a27498b4ae4bef47d8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:14:16 +0200 Subject: [PATCH 244/257] [codex] fix: preserve localhost preview hosts (#3499) Co-authored-by: Codex --- apps/web/src/browser/browserTargetResolver.test.ts | 8 ++++++++ apps/web/src/browser/browserTargetResolver.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 2305812784f..d3c7f6a8dab 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -47,6 +47,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173/app?x=1#top"), + ).toBe("http://localhost:5173/app?x=1#top"); + }); + it("normalizes public URLs without treating them as environment ports", async () => { const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 0a6dc3aa7c2..9142cce1e72 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -24,6 +24,11 @@ const isPrivateNetworkHost = (host: string): boolean => { ); }; +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +}; + export function resolveBrowserNavigationTarget( environmentId: EnvironmentId, target: BrowserNavigationTarget, @@ -68,6 +73,12 @@ export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: const normalizedUrl = normalizePreviewUrl(rawUrl); const parsed = new URL(normalizedUrl); if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.httpBaseUrl); + if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { + return normalizedUrl; + } const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { kind: "environment-port", From 8919ae7c53973048827481ecc3a96d4d778955f7 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:15:43 +0200 Subject: [PATCH 245/257] [codex] Reject unsupported remote pairing protocols (#3498) Co-authored-by: Codex --- packages/shared/src/remote.test.ts | 47 ++++++++++++++++++++++++++++++ packages/shared/src/remote.ts | 23 +++++++++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 54c78907421..24e78757009 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -72,6 +72,53 @@ describe("remote", () => { }); }); + it("rejects unsupported direct pairing URL protocols", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: "ftp://remote.example.com/pair#token=pairing-token", + }); + } catch (cause) { + pairingUrlError = cause; + } + + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect(pairingUrlError).toMatchObject({ protocol: "ftp:" }); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported hosted pairing backend protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: + "https://app.t3.codes/pair?host=ftp%3A%2F%2Fremote.example.com#token=pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "hosted-pairing-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported direct host protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + host: "ftp://remote.example.com", + pairingCode: "pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + it("uses distinct structural errors for missing pairing inputs", () => { expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError); expect(() => diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 703811609b8..7347dbc74a1 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; +const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); @@ -18,7 +19,10 @@ export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( "RemotePairingUrlInvalidError", - { cause: Schema.Defect() }, + { + cause: Schema.optional(Schema.Defect()), + protocol: Schema.optional(Schema.String), + }, ) { override get message(): string { return "Pairing URL is invalid."; @@ -29,7 +33,8 @@ export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass + SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol); + const normalizeRemoteBaseUrl = ( rawValue: string, source: RemoteBackendUrlInvalidError["source"], @@ -83,6 +91,12 @@ const normalizeRemoteBaseUrl = ( } catch (cause) { throw new RemoteBackendUrlInvalidError({ source, cause }); } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemoteBackendUrlInvalidError({ + source, + protocol: url.protocol, + }); + } url.pathname = "/"; url.search = ""; url.hash = ""; @@ -184,6 +198,11 @@ export const resolveRemotePairingTarget = (input: { } catch (cause) { throw new RemotePairingUrlInvalidError({ cause }); } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemotePairingUrlInvalidError({ + protocol: url.protocol, + }); + } const hostedPairingRequest = readHostedPairingRequest(url); if (hostedPairingRequest) { const hostedBackendUrl = normalizeRemoteBaseUrl( From 37ac970e289f6a79940c00e17c5a58114d94d4cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 12:37:31 -0700 Subject: [PATCH 246/257] Persist mobile composer selectors across drafts (#3496) Co-authored-by: codex --- .../features/threads/NewTaskDraftScreen.tsx | 64 ++++-- .../features/threads/ThreadRouteScreen.tsx | 85 ++------ .../threads/new-task-flow-provider.tsx | 185 ++++++++++-------- apps/mobile/src/lib/composer-image-schema.ts | 11 ++ apps/mobile/src/state/thread-outbox-model.ts | 78 ++++++-- apps/mobile/src/state/thread-outbox.test.ts | 79 +++++++- .../src/state/use-composer-drafts.test.ts | 131 ++++++++++++- apps/mobile/src/state/use-composer-drafts.ts | 146 +++++++++++--- .../src/state/use-thread-composer-state.ts | 69 ++++++- .../src/state/use-thread-outbox-drain.ts | 149 ++++++++++---- 10 files changed, 751 insertions(+), 246 deletions(-) create mode 100644 apps/mobile/src/lib/composer-image-schema.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 92c13c20070..ce24198f5e2 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -30,7 +30,9 @@ import { resolveProviderOptionDescriptors, } from "../../lib/providerOptions"; import { buildThreadRoutePath } from "../../lib/routes"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { useProjects } from "../../state/entities"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; @@ -63,6 +65,7 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const promptInputRef = useRef(null); + const loadedBranchesProjectKeyRef = useRef(null); const borderColor = useThemeColor("--color-border"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; @@ -78,6 +81,12 @@ export function NewTaskDraftScreen(props: { ) ?? null; if (directProject) { + if ( + selectedProject?.environmentId === directProject.environmentId && + selectedProject.id === directProject.id + ) { + return; + } setProject(directProject); return; } @@ -105,10 +114,16 @@ export function NewTaskDraftScreen(props: { useEffect(() => { if (!selectedProject) { + loadedBranchesProjectKeyRef.current = null; + return; + } + const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`; + if (loadedBranchesProjectKeyRef.current === projectKey) { return; } + loadedBranchesProjectKeyRef.current = projectKey; void flow.loadBranches(); - }, [flow, selectedProject]); + }, [flow.loadBranches, selectedProject]); useEffect(() => { if (!selectedProject) { @@ -292,11 +307,7 @@ export function NewTaskDraftScreen(props: { if (!event.startsWith("model:")) { return; } - // Defer state update so the native menu dismiss animation completes - // before re-rendering the menu actions (prevents submenu jump). - setTimeout(() => { - flow.setSelectedModelKey(event.slice("model:".length)); - }, 150); + flow.setSelectedModelKey(event.slice("model:".length)); } function handleEnvironmentMenuAction(event: string) { @@ -366,27 +377,42 @@ export function NewTaskDraftScreen(props: { ); async function handleStart(): Promise { + const selectedProject = flow.selectedProject; + if (!selectedProject) { + return; + } + const draft = getComposerDraftSnapshot( + `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`, + ); + const modelSelection = draft.modelSelection ?? flow.selectedModel; + const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; + const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; + const selectedWorktreePath = + draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; + const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; + const interactionMode = draft.interactionMode ?? flow.interactionMode; + const initialMessageText = draft.text.trim(); + if ( - !flow.selectedProject || - !flow.selectedModel || - flow.prompt.trim().length === 0 || + !modelSelection || + initialMessageText.length === 0 || flow.submitting || - (flow.workspaceMode === "worktree" && !flow.selectedBranchName) + (workspaceMode === "worktree" && !selectedBranchName) ) { return; } flow.setSubmitting(true); 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, + project: selectedProject, + modelSelection, + envMode: workspaceMode, + branch: selectedBranchName, + worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, + runtimeMode, + interactionMode, + initialMessageText, + initialAttachments: draft.attachments, }); flow.setSubmitting(false); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7bb74ae88ff..f8c916974e5 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,13 +1,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useState } from "react"; import * as Option from "effect/Option"; -import { - EnvironmentId, - type ModelSelection, - type ProjectScript, - type ProviderInteractionMode, - type RuntimeMode, -} from "@t3tools/contracts"; +import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Pressable, ScrollView, Text as RNText, View } from "react-native"; import { useWorkspaceState } from "../../state/workspace"; @@ -79,18 +73,6 @@ export function ThreadRouteScreen() { const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); - 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<{ @@ -105,6 +87,18 @@ export function ThreadRouteScreen() { const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; + const selectedThreadWithDraftSettings = useMemo( + () => + selectedThread + ? { + ...selectedThread, + modelSelection: composer.modelSelection ?? selectedThread.modelSelection, + runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode, + interactionMode: composer.interactionMode ?? selectedThread.interactionMode, + } + : null, + [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread], + ); /* ─── Native header theming ──────────────────────────────────────── */ const iconColor = String(useThemeColor("--color-icon")); @@ -157,51 +151,6 @@ 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 || @@ -435,7 +384,7 @@ export function ThreadRouteScreen() { (null); - const [selectedModelKey, setSelectedModelKey] = useState(null); - const [workspaceMode, setWorkspaceMode] = useState("local"); - const [selectedBranchName, setSelectedBranchName] = useState(null); - const [selectedWorktreePath, setSelectedWorktreePath] = useState(null); - const branchLoadVersionRef = useRef(0); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const [runtimeMode, setRuntimeMode] = useState(DEFAULT_RUNTIME_MODE); - const [interactionMode, setInteractionMode] = useState( - DEFAULT_PROVIDER_INTERACTION_MODE, - ); - const [modelSelectionOverrides, setModelSelectionOverrides] = useState< - Record - >({}); const [expandedProvider, setExpandedProvider] = useState(null); const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); - setSelectedModelKey(null); - setWorkspaceMode("local"); - setSelectedBranchName(null); - setSelectedWorktreePath(null); setSubmitting(false); setBranchQuery(""); - setRuntimeMode(DEFAULT_RUNTIME_MODE); - setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); - setModelSelectionOverrides({}); setExpandedProvider(null); }, []); @@ -247,33 +229,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; + const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; + const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; + const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, ), - [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection], + [ + selectedEnvironmentServerConfig, + selectedProject?.defaultModelSelection, + selectedProjectDraft.modelSelection, + ], ); - 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) ?? + const selectedModel = + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? modelOptions[0]?.selection ?? null; - const selectedModelIdentity = baseSelectedModel - ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}` + const selectedModelKey = selectedModel + ? `${selectedModel.instanceId}:${selectedModel.model}` : null; - const selectedModel = - (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ?? - baseSelectedModel; const selectedModelOption = modelOptions.find( @@ -282,13 +264,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = - selectedEnvironmentServerConfig?.providers.find( - (provider) => provider.instanceId === selectedModel?.instanceId, - )?.skills ?? []; + const selectedProviderSkills = useMemo( + () => + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + )?.skills ?? [], + [selectedEnvironmentServerConfig, selectedModel?.instanceId], + ); + const setSelectedModelKey = useCallback( + (key: string | null) => { + if (!key || !selectedProjectDraftKey) { + return; + } + const option = modelOptions.find((candidate) => candidate.key === key); + if (!option) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: option.selection, + }); + }, + [modelOptions, selectedProjectDraftKey], + ); const setSelectedModelOptions = useCallback( (options: ReadonlyArray | undefined) => { - if (!selectedModel || !selectedModelIdentity) { + if (!selectedModel || !selectedProjectDraftKey) { return; } const nextSelection: ModelSelection = options @@ -297,12 +297,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { instanceId: selectedModel.instanceId, model: selectedModel.model, }; - setModelSelectionOverrides((current) => ({ - ...current, - [selectedModelIdentity]: nextSelection, - })); + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: nextSelection, + }); }, - [selectedModel, selectedModelIdentity], + [selectedModel, selectedProjectDraftKey], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); @@ -381,62 +380,85 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { 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) => { - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(environmentId); setSelectedProjectKey(null); - setSelectedBranchName(null); - setSelectedWorktreePath(null); - setModelSelectionOverrides({}); }, []); + const setWorkspaceMode = useCallback( + (mode: WorkspaceMode) => { + if (!selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode, + branch: selectedBranchName, + worktreePath: selectedWorktreePath, + }, + }); + }, + [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], + ); + const selectBranch = useCallback( (branch: VcsRef) => { - setSelectedBranchName(branch.name); - setSelectedWorktreePath( - selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null, - ); + if (!selectedProject || !selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: workspaceMode, + branch: branch.name, + worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), + }, + }); }, - [selectedProject], + [selectedProject, selectedProjectDraftKey, workspaceMode], ); + const refreshBranches = branchState.refresh; const loadBranches = useCallback(async () => { if (!selectedProject) { return; } + setPendingConnectionError(null); + refreshBranches(); + }, [refreshBranches, selectedProject]); - const loadVersion = ++branchLoadVersionRef.current; - const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); - branchState.refresh(); - if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { + useEffect(() => { + if (workspaceMode !== "worktree" || selectedBranchName !== null) { 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); - } + const preferredBranch = + availableBranches.find((branch) => branch.current) ?? + availableBranches.find((branch) => branch.isDefault) ?? + null; + if (preferredBranch) { + selectBranch(preferredBranch); } - }, [ - availableBranches, - branchState, - selectedBranchName, - selectedProject, - selectedProjectKey, - workspaceMode, - ]); + }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); + + const setRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); + } + }, + [selectedProjectDraftKey], + ); + const setInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value }); + } + }, + [selectedProjectDraftKey], + ); const value = useMemo( () => ({ @@ -513,6 +535,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setProject, selectBranch, selectEnvironment, + setInteractionMode, + setPrompt, + setRuntimeMode, + setSelectedModelKey, + setWorkspaceMode, submitting, workspaceMode, appendAttachments, diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts new file mode 100644 index 00000000000..a121b70ddb5 --- /dev/null +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -0,0 +1,11 @@ +import * as Schema from "effect/Schema"; + +export const DraftComposerImageAttachmentSchema = Schema.Struct({ + id: Schema.String, + previewUri: Schema.String, + type: Schema.Literal("image"), + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + dataUrl: Schema.String, +}); diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index aa7a1055136..ed0c06ba38b 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,32 +1,38 @@ 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 { + CommandId, + EnvironmentId, + IsoDateTime, + MessageId, + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + type ModelSelection as ModelSelectionType, + type ProviderInteractionMode as ProviderInteractionModeType, + type RuntimeMode as RuntimeModeType, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 1; +const THREAD_OUTBOX_SCHEMA_VERSION = 2; 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), + schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, commandId: CommandId, text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelection), + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), createdAt: IsoDateTime, }); @@ -40,9 +46,37 @@ export interface QueuedThreadMessage { readonly commandId: CommandId; readonly text: string; readonly attachments: ReadonlyArray; + readonly modelSelection?: ModelSelectionType; + readonly runtimeMode?: RuntimeModeType; + readonly interactionMode?: ProviderInteractionModeType; readonly createdAt: string; } +export interface ThreadSettingsSnapshot { + readonly modelSelection: ModelSelectionType; + readonly runtimeMode: RuntimeModeType; + readonly interactionMode: ProviderInteractionModeType; +} + +export function resolveQueuedThreadSettings( + message: QueuedThreadMessage, + thread: ThreadSettingsSnapshot, +): ThreadSettingsSnapshot { + return { + modelSelection: message.modelSelection ?? thread.modelSelection, + runtimeMode: message.runtimeMode ?? thread.runtimeMode, + interactionMode: message.interactionMode ?? thread.interactionMode, + }; +} + +export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean { + return ( + left.instanceId === right.instanceId && + left.model === right.model && + JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) + ); +} + export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { return encodeStoredQueuedThreadMessage({ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, @@ -119,3 +153,21 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { } return isTransportConnectionErrorMessage(errorMessage(error)); } + +export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; +export type ThreadOutboxFailureAction = "retry" | "discard"; + +export function resolveThreadOutboxFailureAction(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; +}): ThreadOutboxFailureAction { + if ( + input.stage === "settings-sync" || + input.interrupted || + shouldRetryThreadOutboxDelivery(input.error) + ) { + return "retry"; + } + return "discard"; +} diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d6b91c1c4f6..68d06d2e424 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from "@effect/vitest"; -import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; import { decodeQueuedThreadMessage, + encodeQueuedThreadMessage, groupQueuedThreadMessages, + modelSelectionsEqual, resolveThreadOutboxDeliveryAction, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadMessage, @@ -66,6 +76,54 @@ describe("thread outbox", () => { ).toThrow(); }); + it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { + const legacyMessage = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const selectedMessage = { + ...legacyMessage, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } satisfies QueuedThreadMessage; + + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( + selectedMessage, + ); + expect( + resolveQueuedThreadSettings(legacyMessage, { + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }), + ).toEqual({ + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }); + }); + + it("compares model options as part of the queued settings change", () => { + const base = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + } as const; + + expect(modelSelectionsEqual(base, base)).toBe(true); + expect( + modelSelectionsEqual(base, { + ...base, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }), + ).toBe(false); + }); + it("backs off queued delivery retries and caps them at sixteen seconds", () => { expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, @@ -271,4 +329,23 @@ describe("thread outbox", () => { ).toBe(true); expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); }); + + it("retains queued messages when settings synchronization fails before startTurn", () => { + const deterministicFailure = new Error("Thread no longer exists"); + + expect( + resolveThreadOutboxFailureAction({ + stage: "settings-sync", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("retry"); + expect( + resolveThreadOutboxFailureAction({ + stage: "start-turn", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("discard"); + }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 48e4e8703f0..d02abb6a265 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,14 +1,136 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts"; +import { appAtomRegistry } from "./atom-registry"; +import { + clearComposerDraftContentState, + composerDraftsAtom, + decodePersistedComposerDrafts, + type ComposerDraft, + getComposerDraftSnapshot, + removeComposerDraftsForEnvironment, +} from "./use-composer-drafts"; const DRAFT: ComposerDraft = { text: "hello", attachments: [], }; +afterEach(() => { + appAtomRegistry.set(composerDraftsAtom, {}); +}); + describe("mobile composer drafts", () => { + it("hydrates selector state even when the message content is empty", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }, + }), + ).toEqual({ + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }); + }); + + it("keeps legacy content-only drafts and rejects invalid selector state", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + }), + ).toEqual({ + "environment-1:thread-1": DRAFT, + }); + + expect(() => + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + ...DRAFT, + runtimeMode: "sometimes-safe", + }, + }, + }), + ).toThrow(); + }); + + it("clears sent content without clearing the selected model or workspace", () => { + const draftKey = "environment-1:thread-1"; + const draft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }; + + expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ + [draftKey]: { + ...draft, + text: "", + attachments: [], + }, + }); + }); + + it("reads the latest selector state synchronously for send", () => { + const draftKey = "environment-1:thread-1"; + const selectedDraft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }; + appAtomRegistry.set(composerDraftsAtom, { [draftKey]: selectedDraft }); + + expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); + }); + it("removes only drafts owned by the selected environment", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); @@ -17,12 +139,15 @@ describe("mobile composer drafts", () => { removeComposerDraftsForEnvironment( { [`${environmentId}:thread-cloud`]: DRAFT, + [`new-task:${environmentId}:project-cloud`]: DRAFT, [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }, environmentId, ), ).toEqual({ [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }); }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index d0329ad2598..9e2c1566190 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,9 +1,18 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + ModelSelection as ModelSelectionSchema, + ProviderInteractionMode as ProviderInteractionModeSchema, + RuntimeMode as RuntimeModeSchema, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; @@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } -interface PersistedComposerDrafts { - readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION; - readonly drafts: Record; +export interface ComposerDraftWorkspaceSelection { + readonly mode: "local" | "worktree"; + readonly branch: string | null; + readonly worktreePath: string | null; } +export type ComposerDraftSettingsUpdate = Pick< + ComposerDraft, + "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" +>; + +const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ + mode: Schema.Literals(["local", "worktree"]), + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), +}); + +const ComposerDraftSchema = Schema.Struct({ + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelectionSchema), + runtimeMode: Schema.optional(RuntimeModeSchema), + interactionMode: Schema.optional(ProviderInteractionModeSchema), + workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), +}); + +const PersistedComposerDraftsSchema = Schema.Struct({ + schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), + drafts: Schema.Record(Schema.String, ComposerDraftSchema), +}); + +const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( + PersistedComposerDraftsSchema, +); + const EMPTY_DRAFT: ComposerDraft = { text: "", attachments: [], @@ -54,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { return EMPTY_DRAFT; } return { + ...draft, text: draft.text, attachments: draft.attachments, }; } +export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { + return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); +} + function isEmptyDraft(draft: ComposerDraft): boolean { - return draft.text.length === 0 && draft.attachments.length === 0; + return ( + draft.text.length === 0 && + draft.attachments.length === 0 && + draft.modelSelection === undefined && + draft.runtimeMode === undefined && + draft.interactionMode === undefined && + draft.workspaceSelection === undefined + ); +} + +export function decodePersistedComposerDrafts(value: unknown): Record { + const parsed = decodePersistedComposerDraftsDocument(value); + return Object.fromEntries( + Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), + ); } async function getComposerDraftsFile() { @@ -80,20 +142,7 @@ async function loadPersistedComposerDrafts(): Promise; - if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { - return {}; - } - return Object.fromEntries( - Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => { - const draft = entry[1]; - return ( - typeof draft?.text === "string" && - Array.isArray(draft.attachments) && - !isEmptyDraft(draft) - ); - }), - ); + return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", @@ -116,10 +165,10 @@ async function writePersistedComposerDrafts(drafts: Record !isEmptyDraft(draft)), ); - const document: PersistedComposerDrafts = { + const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, - }; + } as const; const encoded = JSON.stringify(document); operation = "write"; if (!file.exists) { @@ -282,6 +331,55 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) }); } +export function updateComposerDraftSettings( + draftKey: string, + settings: Partial, +): void { + updateComposerDrafts((current) => { + const draft = { + ...normalizeDraft(current[draftKey]), + ...settings, + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; + }); +} + +export function clearComposerDraftContentState( + current: Record, + draftKey: string, +): Record { + const existing = current[draftKey]; + if (!existing) { + return current; + } + const draft = { + ...existing, + text: "", + attachments: [], + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; +} + +export function clearComposerDraftContent(draftKey: string): void { + updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); +} + export function clearComposerDraft(draftKey: string): void { updateComposerDrafts((current) => { if (!current[draftKey]) { @@ -298,8 +396,12 @@ export function removeComposerDraftsForEnvironment( environmentId: EnvironmentId, ): Record { const environmentPrefix = `${environmentId}:`; + const newTaskPrefix = `new-task:${environmentId}:`; return Object.fromEntries( - Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)), + Object.entries(drafts).filter( + ([draftKey]) => + !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix), + ), ); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 60970b32a4d..0b8cba16e16 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,7 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; -import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; @@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry"; import { appendComposerDraftAttachments, appendComposerDraftText, - clearComposerDraft, + clearComposerDraftContent, composerDraftsAtom, ensureComposerDraftsLoaded, + getComposerDraftSnapshot, removeComposerDraftAttachment, setComposerDraftText, + updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; @@ -98,6 +108,10 @@ export function useThreadComposerState() { const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; + const selectedThread = selectedThreadDetail ?? selectedThreadShell; + const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; + const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; @@ -130,7 +144,6 @@ export function useThreadComposerState() { selectedThreadShell, ]); - const selectedThread = selectedThreadDetail ?? selectedThreadShell; const activeThreadBusy = !!selectedThread && (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); @@ -141,9 +154,10 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const draft = composerDrafts[threadKey]; - const text = (draft?.text ?? "").trim(); - const attachments = draft?.attachments ?? []; + const draft = getComposerDraftSnapshot(threadKey); + const thread = selectedThreadDetail ?? selectedThreadShell; + const text = draft.text.trim(); + const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { return; } @@ -157,15 +171,18 @@ export function useThreadComposerState() { commandId: CommandId.make(metadata.commandId), text, attachments, + modelSelection: draft.modelSelection ?? thread.modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: draft.interactionMode ?? thread.interactionMode, createdAt: metadata.createdAt, }); - clearComposerDraft(threadKey); + clearComposerDraftContent(threadKey); } catch (error) { setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); } - }, [composerDrafts, selectedThreadShell]); + }, [selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { @@ -255,12 +272,45 @@ export function useThreadComposerState() { [selectedThreadShell], ); + const onUpdateModelSelection = useCallback( + (value: ModelSelection) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { modelSelection: value }); + }, + [selectedThreadKey], + ); + + const onUpdateRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + }, + [selectedThreadKey], + ); + + const onUpdateInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { interactionMode: value }); + }, + [selectedThreadKey], + ); + return { selectedThreadFeed, selectedThreadQueueCount, activeWorkStartedAt, draftMessage, draftAttachments, + modelSelection, + runtimeMode, + interactionMode, activeThreadBusy, onChangeDraftMessage, onPickDraftImages, @@ -268,5 +318,8 @@ export function useThreadComposerState() { onNativePasteImages, onRemoveDraftImage, onSendMessage, + onUpdateModelSelection, + onUpdateRuntimeMode, + onUpdateInteractionMode, }; } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 840456e2d1c..e912d6366b4 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { type MessageId } from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { CommandId, type MessageId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry"; import { useThreadShells } from "./entities"; import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; import { + modelSelectionsEqual, resolveThreadOutboxDeliveryAction, - shouldRetryThreadOutboxDelivery, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, threadOutboxRetryDelayMs, type QueuedThreadMessage, + type ThreadOutboxCommandStage, } from "./thread-outbox-model"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; @@ -44,8 +48,21 @@ function findThread( ); } +function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { + return CommandId.make(`${message.commandId}:${setting}`); +} + export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + reportFailure: false, + }); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const shellStatuses = useThreadOutboxShellStatuses(); @@ -68,6 +85,98 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { + const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const reportFailure = ( + commandResult: AtomCommandResult, + stage: ThreadOutboxCommandStage, + ): boolean => { + if (!AsyncResult.isFailure(commandResult)) { + return false; + } + const action = resolveThreadOutboxFailureAction({ + stage, + error: Cause.squash(commandResult.cause), + interrupted: Cause.hasInterruptsOnly(commandResult.cause), + }); + const retry = action === "retry"; + console.warn("[thread-outbox] queued message delivery failed", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + stage, + cause: commandResult.cause, + retry, + }); + return retry; + }; + const completeDelivery = async ( + deliveryResult: AtomCommandResult, + ): Promise => { + if (reportFailure(deliveryResult, "start-turn")) { + return false; + } + + try { + await removeThreadOutboxMessage(queuedMessage); + return true; + } catch (error) { + console.warn("[thread-outbox] failed to remove delivered queued message", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + error, + }); + return false; + } + }; + + if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { + const updateResult = await updateThreadMetadata({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "model-selection"), + threadId: queuedMessage.threadId, + modelSelection: settings.modelSelection, + }, + }); + if (AsyncResult.isFailure(updateResult)) { + reportFailure(updateResult, "settings-sync"); + return false; + } + } + + if (settings.runtimeMode !== thread.runtimeMode) { + const runtimeResult = await setThreadRuntimeMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "runtime-mode"), + threadId: queuedMessage.threadId, + runtimeMode: settings.runtimeMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(runtimeResult)) { + reportFailure(runtimeResult, "settings-sync"); + return false; + } + } + + if (settings.interactionMode !== thread.interactionMode) { + const interactionResult = await setThreadInteractionMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "interaction-mode"), + threadId: queuedMessage.threadId, + interactionMode: settings.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(interactionResult)) { + reportFailure(interactionResult, "settings-sync"); + return false; + } + } + const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -79,41 +188,15 @@ export function useThreadOutboxDrain(): void { text: queuedMessage.text, attachments: queuedMessage.attachments, }, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, + modelSelection: settings.modelSelection, + runtimeMode: settings.runtimeMode, + interactionMode: settings.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; - } + return completeDelivery(deliveryResult); }, - [startTurn], + [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata], ); useEffect(() => { From f5f98cf0ae48a14b524480a0942f9ce37190656c Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:52:13 -0700 Subject: [PATCH 247/257] Stabilize composer provider state while typing (#3507) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ChatComposer.tsx | 9 ++++++-- .../chat/composerProviderState.test.tsx | 22 ++++++++++++------- .../components/chat/composerProviderState.tsx | 12 +++++++--- apps/web/src/modelSelection.ts | 2 -- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3a5e06bce06..11feae3e501 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -78,6 +78,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { + getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -791,18 +792,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [selectedProviderEntry], ); + const composerPromptInjectionState = useMemo( + () => getComposerPromptInjectionState(prompt), + [prompt], + ); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, models: selectedProviderModels, - prompt, + promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], }), [ composerModelOptions, - prompt, + composerPromptInjectionState, selectedInstanceId, selectedModel, selectedProvider, diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 07eec55de2c..067e71ef1bf 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -6,6 +6,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { + getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -61,6 +62,13 @@ const ULTRATHINK_FRAME_CLASSES = { } as const; describe("getComposerProviderState", () => { + it("derives a stable prompt injection state for ordinary prompt edits", () => { + expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); + expect(getComposerPromptInjectionState("Ultrathink:\nInvestigate this failure")).toBe( + "ultrathink", + ); + }); + it("returns descriptor defaults when no selections are provided", () => { const state = getComposerProviderState({ provider: PROVIDER, @@ -71,7 +79,6 @@ describe("getComposerProviderState", () => { { id: "high", label: "High", isDefault: true }, ]), ]), - prompt: "", modelOptions: undefined, }); @@ -93,7 +100,6 @@ describe("getComposerProviderState", () => { ]), booleanDescriptor("fastMode"), ]), - prompt: "", modelOptions: selections(["effort", "low"], ["fastMode", true]), }); @@ -112,7 +118,6 @@ describe("getComposerProviderState", () => { selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), booleanDescriptor("fastMode"), ]), - prompt: "", modelOptions: selections(["effort", "high"], ["fastMode", false]), }); @@ -126,7 +131,6 @@ describe("getComposerProviderState", () => { provider: PROVIDER, model: MODEL, models: modelWith([booleanDescriptor("thinking")]), - prompt: "", modelOptions: selections(["effort", "max"], ["thinking", false]), }); @@ -152,7 +156,6 @@ describe("getComposerProviderState", () => { { id: "plan", label: "Plan" }, ]), ]), - prompt: "", modelOptions: selections(["agent", "plan"]), }); @@ -167,7 +170,6 @@ describe("getComposerProviderState", () => { provider: PROVIDER, model: MODEL, models: modelWith([]), - prompt: "", modelOptions: selections(["anything", "value"]), }); @@ -193,7 +195,9 @@ describe("getComposerProviderState", () => { ["ultrathink"], ), ]), - prompt: "Ultrathink:\nInvestigate this failure", + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), modelOptions: selections(["effort", "medium"]), }); @@ -212,7 +216,9 @@ describe("getComposerProviderState", () => { models: modelWith([ selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), ]), - prompt: "Ultrathink:\nInvestigate this failure", + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), modelOptions: undefined, }); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index b5cc790538d..1349e2509b7 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -21,10 +21,12 @@ export type ComposerProviderStateInput = { provider: ProviderDriverKind; model: string; models: ReadonlyArray; - prompt: string; + promptInjectionState?: ComposerPromptInjectionState; modelOptions: ReadonlyArray | null | undefined; }; +export type ComposerPromptInjectionState = "none" | "ultrathink"; + export type ComposerProviderState = { provider: ProviderDriverKind; promptEffort: string | null; @@ -46,8 +48,12 @@ type TraitsRenderInput = { onPromptChange: (prompt: string) => void; }; +export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { + return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; +} + export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - const { provider, model, models, prompt, modelOptions } = input; + const { provider, model, models, modelOptions, promptInjectionState = "none" } = input; const caps = getProviderModelCapabilities(models, model, provider); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); const primarySelectDescriptor = descriptors.find( @@ -58,7 +64,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com const promptEffort = typeof primaryValue === "string" ? primaryValue : null; const ultrathinkActive = (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && - isClaudeUltrathinkPrompt(prompt); + promptInjectionState === "ultrathink"; return { provider, diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 0fcf680b732..7ede9665ac9 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -303,7 +303,6 @@ export function resolveAppModelSelectionState( provider, model, models: entry.models, - prompt: "", modelOptions: selectedEntry ? selection.options : undefined, }); @@ -321,7 +320,6 @@ export function resolveAppModelSelectionState( provider, model, models: getProviderModels(providers, provider), - prompt: "", modelOptions: keptSelectedProvider ? selection.options : undefined, }); From fb1034546f989c11568ef1c4b5801e6a5372a07d Mon Sep 17 00:00:00 2001 From: Abdul Azeez Date: Tue, 23 Jun 2026 01:44:24 +0530 Subject: [PATCH 248/257] feat: add persistent word-wrap setting for chat code blocks and tables (#3480) Co-authored-by: Julius Marminge --- .../settings/DesktopClientSettings.test.ts | 2 +- apps/web/src/clientPersistenceStorage.test.ts | 21 +++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 6 +++-- apps/web/src/components/DiffPanel.tsx | 17 ++++++------ .../src/components/files/FilePreviewPanel.tsx | 16 ++++++++--- .../components/settings/SettingsPanels.tsx | 24 ++++++++--------- packages/contracts/src/settings.test.ts | 27 +++++++++++++++++-- packages/contracts/src/settings.ts | 4 +-- 8 files changed, 85 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 3584d6a21e4..ea7ec6e1512 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,7 +18,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, - diffWordWrap: true, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", @@ -29,6 +28,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", + wordWrap: true, }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index ec335892bea..8f849a6e7b3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -69,4 +69,25 @@ describe("clientPersistenceStorage", () => { }), ); }); + + it("defaults word wrap on and discards obsolete wrapping preferences", async () => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + chatWordWrap: false, + diffWordWrap: false, + }), + ); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + const settings = readBrowserClientSettings(); + + expect(settings).toEqual( + expect.objectContaining({ + wordWrap: true, + }), + ); + expect(settings).not.toHaveProperty("chatWordWrap"); + expect(settings).not.toHaveProperty("diffWordWrap"); + }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 711a545d90a..47604d23ca2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -54,6 +54,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; +import { useClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, @@ -295,7 +296,7 @@ function getHighlighterPromise(language: string): Promise { function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(useClientSettings((settings) => settings.wordWrap)); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; @@ -525,10 +526,11 @@ function MarkdownCodeBlock({ children: ReactNode; }) { const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(false); + const [wrapped, setWrapped] = useState(useClientSettings((settings) => settings.wordWrap)); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; + const handleCopy = useCallback(() => { if (typeof navigator === "undefined" || navigator.clipboard == null) { return; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e41ba9c2440..cbcd36ce05e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -186,7 +186,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); - const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); + const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ @@ -194,6 +194,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); const codeViewRef = useRef(null); + const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), @@ -695,14 +696,12 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff { - setDiffWordWrap(Boolean(pressed)); + setWordWrap(Boolean(pressed)); }} /> } @@ -710,7 +709,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff - {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} @@ -844,7 +843,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff options={{ diffStyle: diffRenderMode === "split" ? "split" : "unified", lineDiffType: "none", - overflow: diffWordWrap ? "wrap" : "scroll", + overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme as DiffThemeType, unsafeCSS: DIFF_PANEL_UNSAFE_CSS, @@ -860,7 +859,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
 void;
 }
@@ -296,6 +298,7 @@ function EditableFileSurface({
   contents,
   resolvedTheme,
   revealRequestId,
+  wordWrap,
   onPostRender,
   onPendingChange,
 }: EditableFileSurfaceProps) {
@@ -516,7 +519,7 @@ function EditableFileSurface({
               onGutterUtilityClick: setSelectedRange,
               onLineSelectionChange: setSelectedRange,
               onLineSelectionEnd: handleLineSelectionEnd,
-              overflow: "scroll",
+              overflow: wordWrap ? "wrap" : "scroll",
               theme: resolveDiffThemeName(resolvedTheme),
               themeType: resolvedTheme,
               unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
@@ -557,7 +560,12 @@ function RenderedMarkdownSurface({
   onPendingChange,
 }: Omit<
   EditableFileSurfaceProps,
-  "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender"
+  | "resolvedTheme"
+  | "composerDraftTarget"
+  | "revealLine"
+  | "revealRequestId"
+  | "wordWrap"
+  | "onPostRender"
 > & {
   threadRef: ScopedThreadRef;
 }) {
@@ -613,6 +621,7 @@ export default function FilePreviewPanel({
   onPendingChange,
 }: FilePreviewPanelProps) {
   const { resolvedTheme } = useTheme();
+  const wordWrap = useClientSettings((settings) => settings.wordWrap);
   const primaryEnvironmentId = usePrimaryEnvironmentId();
   const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
   const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
@@ -844,7 +853,7 @@ export default function FilePreviewPanel({
                   }}
                   options={{
                     disableFileHeader: true,
-                    overflow: "scroll",
+                    overflow: wordWrap ? "wrap" : "scroll",
                     theme: resolveDiffThemeName(resolvedTheme),
                     themeType: resolvedTheme,
                     unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
@@ -863,6 +872,7 @@ export default function FilePreviewPanel({
                 contents={file.data.contents}
                 resolvedTheme={resolvedTheme}
                 revealRequestId={revealRequestId}
+                wordWrap={wordWrap}
                 onPostRender={onFilePostRender}
                 onPendingChange={onPendingChange}
               />
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 994cbb08f23..40017d56314 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -391,9 +391,7 @@ export function useSettingsRestore(onRestored?: () => void) {
       ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
         ? ["Visible threads"]
         : []),
-      ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
-        ? ["Diff line wrapping"]
-        : []),
+      ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
       ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
         ? ["Diff whitespace changes"]
         : []),
@@ -434,11 +432,11 @@ export function useSettingsRestore(onRestored?: () => void) {
       settings.defaultThreadEnvMode,
       settings.newWorktreesStartFromOrigin,
       settings.diffIgnoreWhitespace,
-      settings.diffWordWrap,
       settings.automaticGitFetchInterval,
       settings.enableAssistantStreaming,
       settings.sidebarThreadPreviewCount,
       settings.timestampFormat,
+      settings.wordWrap,
       theme,
     ],
   );
@@ -456,7 +454,7 @@ export function useSettingsRestore(onRestored?: () => void) {
     setTheme("system");
     updateSettings({
       timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
-      diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
+      wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
       diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
       sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
       autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
@@ -594,15 +592,15 @@ export function GeneralSettingsPanel() {
         />
 
         
                   updateSettings({
-                    diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
+                    wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
                   })
                 }
               />
@@ -610,9 +608,9 @@ export function GeneralSettingsPanel() {
           }
           control={
              updateSettings({ diffWordWrap: Boolean(checked) })}
-              aria-label="Wrap diff lines by default"
+              checked={settings.wordWrap}
+              onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })}
+              aria-label="Wrap code, tables, diffs, and file previews by default"
             />
           }
         />
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index aba97cbe205..ac2d47ca336 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -2,12 +2,35 @@ import { describe, expect, it } from "vite-plus/test";
 import * as Schema from "effect/Schema";
 
 import { ProviderInstanceId } from "./providerInstance.ts";
-import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts";
-
+import {
+  ClientSettingsSchema,
+  DEFAULT_SERVER_SETTINGS,
+  ServerSettings,
+  ServerSettingsPatch,
+} from "./settings.ts";
+
+const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
 const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
 const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
 const encodeServerSettings = Schema.encodeSync(ServerSettings);
 
+describe("ClientSettings word wrap", () => {
+  it("defaults word wrap on", () => {
+    expect(decodeClientSettings({}).wordWrap).toBe(true);
+  });
+
+  it("ignores obsolete wrapping preferences", () => {
+    const decoded = decodeClientSettings({
+      chatWordWrap: false,
+      diffWordWrap: false,
+    });
+
+    expect(decoded.wordWrap).toBe(true);
+    expect(decoded).not.toHaveProperty("chatWordWrap");
+    expect(decoded).not.toHaveProperty("diffWordWrap");
+  });
+});
+
 describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
   it("defaults to an empty record so legacy configs without the key still decode", () => {
     expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 7ba267b1e72..6ccd65533dd 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -47,7 +47,6 @@ export const ClientSettingsSchema = Schema.Struct({
     Schema.withDecodingDefault(Effect.succeed([])),
   ),
   diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
-  diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
   // Model favorites. Historically keyed by provider kind, now
   // widened to `ProviderInstanceId` so users can favorite a specific model
   // on a custom provider instance (e.g. "Codex Personal · gpt-5") without
@@ -92,6 +91,7 @@ export const ClientSettingsSchema = Schema.Struct({
   timestampFormat: TimestampFormat.pipe(
     Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
   ),
+  wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
 });
 export type ClientSettings = typeof ClientSettingsSchema.Type;
 
@@ -538,7 +538,6 @@ export const ClientSettingsPatch = Schema.Struct({
   confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
   confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
   diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
-  diffWordWrap: Schema.optionalKey(Schema.Boolean),
   favorites: Schema.optionalKey(
     Schema.Array(
       Schema.Struct({
@@ -568,5 +567,6 @@ export const ClientSettingsPatch = Schema.Struct({
   sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
   sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
   timestampFormat: Schema.optionalKey(TimestampFormat),
+  wordWrap: Schema.optionalKey(Schema.Boolean),
 });
 export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;

From b2d17b7108766a989374d670ff12de39b6c4d033 Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Mon, 22 Jun 2026 14:00:30 -0700
Subject: [PATCH 249/257] Add main sidebar toggle (#3497)

Co-authored-by: Julius Marminge 
Co-authored-by: codex 
---
 apps/server/src/keybindings.test.ts           |   1 +
 apps/web/src/components/AppSidebarLayout.tsx  |  57 +++++++++-
 apps/web/src/components/ChatView.tsx          |   4 +-
 .../src/components/NoActiveThreadState.tsx    |   7 +-
 apps/web/src/components/Sidebar.tsx           | 106 +++++++++---------
 apps/web/src/components/chat/ChatHeader.tsx   |   2 -
 apps/web/src/components/ui/sidebar.test.tsx   |  33 ++++++
 apps/web/src/components/ui/sidebar.tsx        |  23 +++-
 apps/web/src/components/ui/sidebarState.ts    |   9 ++
 apps/web/src/index.css                        |  33 ++++++
 apps/web/src/keybindings.test.ts              |   5 +
 apps/web/src/routes/_chat.index.tsx           |  12 +-
 apps/web/src/routes/settings.tsx              |  19 +++-
 apps/web/src/workspaceTitlebar.ts             |   2 +
 packages/contracts/src/keybindings.test.ts    |   6 +
 packages/contracts/src/keybindings.ts         |   1 +
 packages/shared/src/keybindings.ts            |   1 +
 17 files changed, 247 insertions(+), 74 deletions(-)
 create mode 100644 apps/web/src/components/ui/sidebarState.ts
 create mode 100644 apps/web/src/workspaceTitlebar.ts

diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts
index ba95422735c..a51ad20afbe 100644
--- a/apps/server/src/keybindings.test.ts
+++ b/apps/server/src/keybindings.test.ts
@@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
       assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
       assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
       assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
+      assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
       assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
       assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
       assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index cbfce7b43d0..0f1a8f9d429 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -1,14 +1,64 @@
-import { useEffect, type ReactNode } from "react";
+import { useAtomValue } from "@effect/atom-react";
+import { useEffect, type CSSProperties, type ReactNode } from "react";
 import { useNavigate } from "@tanstack/react-router";
 
+import { isElectron } from "../env";
+import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
+import { isMacPlatform } from "../lib/utils";
+import { primaryServerKeybindingsAtom } from "../state/server";
 import ThreadSidebar from "./Sidebar";
-import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
+import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
 
 const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
 const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
 const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
+const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";
+
+function SidebarControl() {
+  const keybindings = useAtomValue(primaryServerKeybindingsAtom);
+  const { toggleSidebar } = useSidebar();
+  const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");
+
+  useEffect(() => {
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.defaultPrevented) return;
+      if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return;
+
+      event.preventDefault();
+      event.stopPropagation();
+      toggleSidebar();
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  }, [keybindings, toggleSidebar]);
+
+  return (
+    
+ + + } + /> + + Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + + +
+ ); +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const macosWindowControlsStyle = + isElectron && isMacPlatform(navigator.platform) + ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) + : undefined; useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -28,7 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + {children} + ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf5bb9de5e9..44429614b44 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -128,6 +128,7 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -4677,7 +4678,7 @@ function ChatViewContent(props: ChatViewProps) {
{!rightPanelOpen ? panelLayoutControls : null} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index c874ee58a98..68a5855c1a2 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,7 +1,8 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; -import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; +import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export function NoActiveThreadState() { return ( @@ -9,8 +10,9 @@ export function NoActiveThreadState() {
{isElectron ? ( @@ -19,7 +21,6 @@ export function NoActiveThreadState() { ) : (
- No active thread diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3ed88bd3b9..ce925618caa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -70,7 +70,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -187,12 +187,12 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - resolveSidebarStageBadgeLabel, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -2452,22 +2452,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); -function T3Wordmark() { - return ( - - - - ); -} - type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" @@ -2664,48 +2648,64 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { + return isElectron ? ( + + + + + ) : ( + + + + + ); +}); + +function SidebarBrand() { + const stageLabel = useSidebarStageLabel(); + + return ( + + + + Code + + + {stageLabel} + + + ); +} + +function useSidebarStageLabel() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - const stageBadgeLabel = resolveSidebarStageBadgeLabel({ + + return resolveSidebarStageBadgeLabel({ primaryServerVersion, fallbackStageLabel: APP_STAGE_LABEL, }); - const wordmark = ( -
- - - - - - Code - - - {stageBadgeLabel} - - - } - /> - - Version {APP_VERSION} - - -
- ); +} - return isElectron ? ( - - {wordmark} - - ) : ( - {wordmark} +function T3Wordmark() { + return ( + + + ); -}); +} const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index efc160b0bd1..ef3ec863d0b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -14,7 +14,6 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { cn } from "~/lib/utils"; @@ -80,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({ return (
- { + it("uses mobile sheet visibility for the shared responsive state", () => { + expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe( + "collapsed", + ); + expect(resolveSidebarState({ isMobile: true, open: false, openMobile: true })).toBe("expanded"); + expect(resolveSidebarState({ isMobile: false, open: true, openMobile: false })).toBe( + "expanded", + ); + }); + + it("exposes collapsed state for shared titlebar inset styling", () => { + const html = renderToStaticMarkup( + +
+ , + ); + + expect(html).toContain('data-sidebar-state="collapsed"'); + }); + + it("keeps the sidebar trigger interactive inside Electron drag regions", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("[-webkit-app-region:no-drag]"); + expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!"); + }); + it("uses a pointer cursor for menu buttons by default", () => { const html = renderSidebarButton(); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 718fc22b3fe..097568f77f0 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; @@ -29,7 +30,7 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; type SidebarContextProps = { - state: "expanded" | "collapsed"; + state: ResponsiveSidebarState; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; @@ -85,6 +86,11 @@ function useSidebar() { return context; } +function useSidebarVisibility() { + const { isMobile, open, openMobile } = useSidebar(); + return isMobile ? openMobile : open; +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -132,7 +138,7 @@ function SidebarProvider({ // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; + const state = resolveSidebarState({ isMobile, open, openMobile }); const contextValue = React.useMemo( () => ({ @@ -154,6 +160,7 @@ function SidebarProvider({ "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className, )} + data-sidebar-state={state} data-slot="sidebar-wrapper" style={ { @@ -310,13 +317,18 @@ function Sidebar({ } function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { - const { toggleSidebar, openMobile } = useSidebar(); + const { toggleSidebar } = useSidebar(); + const isOpen = useSidebarVisibility(); return ( ); @@ -1004,4 +1016,5 @@ export { SidebarSeparator, SidebarTrigger, useSidebar, + useSidebarVisibility, }; diff --git a/apps/web/src/components/ui/sidebarState.ts b/apps/web/src/components/ui/sidebarState.ts new file mode 100644 index 00000000000..fcdfed10521 --- /dev/null +++ b/apps/web/src/components/ui/sidebarState.ts @@ -0,0 +1,9 @@ +export type ResponsiveSidebarState = "expanded" | "collapsed"; + +export function resolveSidebarState(input: { + isMobile: boolean; + open: boolean; + openMobile: boolean; +}): ResponsiveSidebarState { + return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed"; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 09ef006a638..148e8783b4a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -7,13 +7,24 @@ :root { --workspace-topbar-height: 52px; --workspace-controls-top: 0px; + --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem); --workspace-native-controls-inset: 0px; + --workspace-titlebar-control-size: 1.75rem; + --workspace-titlebar-control-gap: 0.75rem; +} + +[data-slot="sidebar-wrapper"] { + --workspace-titlebar-content-left: calc( + var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + + var(--workspace-titlebar-control-gap) + ); } .wco { --workspace-topbar-height: env(titlebar-area-height, 52px); --workspace-controls-top: env(titlebar-area-y, 0px); + --workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem); --workspace-controls-right: calc( 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem ); @@ -90,6 +101,28 @@ } @layer components { + .sidebar-brand { + display: none; + } + + .sidebar-brand-stage { + display: none; + } + + @media (min-width: 48rem) { + @container sidebar-header (min-width: 13.5rem) { + .sidebar-brand { + display: flex; + } + } + + @container sidebar-header (min-width: 15.75rem) { + .sidebar-brand-stage { + display: inline-flex; + } + } + } + .workspace-topbar { display: flex; height: var(--workspace-topbar-height); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d4fc945cc04..c0d326edd55 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -85,6 +85,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { } const DEFAULT_BINDINGS = compile([ + { shortcut: modShortcut("b"), command: "sidebar.toggle" }, { shortcut: modShortcut("j"), command: "terminal.toggle" }, { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, { @@ -312,6 +313,10 @@ describe("shortcutLabelForCommand", () => { }); it("returns effective labels for non-terminal commands", () => { + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", + ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 7be0f50414e..94d49d00afe 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,10 +4,12 @@ import { LinkIcon, PlusIcon } from "lucide-react"; import { NoActiveThreadState } from "../components/NoActiveThreadState"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -30,9 +32,13 @@ function HostedStaticOnboardingState() { return (
-
+
- {APP_DISPLAY_NAME} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index fa5c6a4201d..40507321066 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -11,8 +11,10 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -64,9 +66,13 @@ function SettingsContentLayout() {
{!isElectron && ( -
+
- Settings {showRestoreDefaults ? (
@@ -78,7 +84,12 @@ function SettingsContentLayout() { )} {isElectron && ( -
+
Settings diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts new file mode 100644 index 00000000000..b481221e63a --- /dev/null +++ b/apps/web/src/workspaceTitlebar.ts @@ -0,0 +1,2 @@ +export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = + "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 19c98c390c3..33ecd38039f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsed.command, "terminal.toggle"); + const parsedSidebarToggle = yield* decode(KeybindingRule, { + key: "mod+b", + command: "sidebar.toggle", + }); + assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); + const parsedRightPanelToggle = yield* decode(KeybindingRule, { key: "mod+alt+b", command: "rightPanel.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 4a5ffd0c3dd..c7cff9943cd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -48,6 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; const STATIC_KEYBINDING_COMMANDS = [ + "sidebar.toggle", "terminal.toggle", "terminal.split", "terminal.splitVertical", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 4abe53f2053..b6bdd7b4783 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -19,6 +19,7 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+b", command: "sidebar.toggle" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, From 8cc9a6fa6b0e3b6d6528793e942982d7de040833 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 15:58:54 -0700 Subject: [PATCH 250/257] [codex] Restore T3 Connect account controls (#3492) Co-authored-by: Julius Marminge --- apps/web/src/cloud/linkEnvironment.test.ts | 33 ++++ apps/web/src/cloud/linkEnvironmentAtoms.ts | 9 + ...MobileClientsUserProfilePage.logic.test.ts | 65 +++++++ .../MobileClientsUserProfilePage.logic.ts | 39 ++++ .../clerk/MobileClientsUserProfilePage.tsx | 166 ++++++++++++++++++ .../clerk/T3ConnectSidebarSignIn.tsx | 13 +- .../settings/ConnectionsSettings.tsx | 127 ++++++++++---- 7 files changed, 417 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts create mode 100644 apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts create mode 100644 apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index f823016ddf0..7e6f2365e50 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -33,6 +33,7 @@ import { readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; const TARGET: CloudLinkTarget = { @@ -252,6 +253,38 @@ describe("web cloud link environment client", () => { }), ); + it.effect("updates agent activity publishing for the explicit primary target", () => + 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: true, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const state = yield* withServices( + updatePrimaryCloudPreferences({ + target: TARGET, + publishAgentActivity: true, + }), + ); + + expect(state.publishAgentActivity).toBe(true); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/preferences", + ); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + publishAgentActivity: true, + }); + }), + ); + it.effect("links an available primary environment without invoking installation", () => Effect.gen(function* () { const fetchMock = vi diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts index ea924cae234..4cb62271a48 100644 --- a/apps/web/src/cloud/linkEnvironmentAtoms.ts +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -8,6 +8,7 @@ import { linkPrimaryEnvironmentToCloud, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; const cloudLinkScheduler = createAtomCommandScheduler(); @@ -31,3 +32,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => unlinkPrimaryEnvironmentFromCloud(input), }); + +export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:update-primary-environment-preferences", + scheduler: cloudLinkScheduler, + concurrency: cloudLinkConcurrency, + execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) => + updatePrimaryCloudPreferences(input), +}); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts new file mode 100644 index 00000000000..fcc660e8305 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -0,0 +1,65 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; + +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +function device(overrides: Partial = {}): RelayClientDeviceRecord { + return { + deviceId: "device-1", + label: "Julius’s iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.2.3", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: false, + notifyOnCompletion: true, + notifyOnFailure: false, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-06-21T12:00:00.000Z", + ...overrides, + }; +} + +describe("mobile client presentation", () => { + it("describes the client platform and enabled notification events", () => { + const client = device(); + + expect(mobileClientPlatformLabel(client)).toBe("iOS 18 · T3 Code 1.2.3"); + expect(mobileClientNotificationDetail(client)).toBe( + "Alerts enabled for approvals, completions.", + ); + }); + + it("distinguishes disabled notifications from an empty event selection", () => { + expect( + mobileClientNotificationDetail( + device({ notifications: { ...device().notifications, enabled: false } }), + ), + ).toBe("Push notifications are disabled on this device."); + expect( + mobileClientNotificationDetail( + device({ + notifications: { + enabled: true, + notifyOnApproval: false, + notifyOnInput: false, + notifyOnCompletion: false, + notifyOnFailure: false, + }, + }), + ), + ).toBe("Push notifications are enabled, but no alert types are selected."); + }); + + it("handles missing app versions and invalid update timestamps", () => { + expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); + expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable"); + }); +}); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts new file mode 100644 index 00000000000..5ca9595bef4 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -0,0 +1,39 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; + +const mobileClientUpdatedAtFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +const NOTIFICATION_PREFERENCES = [ + ["notifyOnApproval", "approvals"], + ["notifyOnInput", "input requests"], + ["notifyOnCompletion", "completions"], + ["notifyOnFailure", "failures"], +] as const satisfies ReadonlyArray< + readonly [keyof RelayClientDeviceRecord["notifications"], string] +>; + +export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { + return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; +} + +export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { + if (!device.notifications.enabled) { + return "Push notifications are disabled on this device."; + } + + const enabledPreferences = NOTIFICATION_PREFERENCES.flatMap(([preference, label]) => + device.notifications[preference] ? [label] : [], + ); + return enabledPreferences.length > 0 + ? `Alerts enabled for ${enabledPreferences.join(", ")}.` + : "Push notifications are enabled, but no alert types are selected."; +} + +export function mobileClientUpdatedAtLabel(updatedAt: string): string { + const date = new Date(updatedAt); + return Number.isNaN(date.getTime()) + ? "Update time unavailable" + : `Updated ${mobileClientUpdatedAtFormatter.format(date)}`; +} diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx new file mode 100644 index 00000000000..26af10ba5b8 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -0,0 +1,166 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; + +import { useManagedRelayDevices } from "../../cloud/managedRelayState"; +import { cn } from "../../lib/utils"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Skeleton } from "../ui/skeleton"; +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; + +function MobileClientStatusBadge({ + enabled, + label, +}: { + readonly enabled: boolean; + readonly label: string; +}) { + return ( + + {label}: {enabled ? "On" : "Off"} + + ); +} + +function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { + return ( +
  • +
    +
    + +
    +
    +
    +
    +

    {device.label}

    +

    {mobileClientPlatformLabel(device)}

    +
    +

    + {mobileClientUpdatedAtLabel(device.updatedAt)} +

    +
    +
    + + +
    +

    + {mobileClientNotificationDetail(device)} +

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

    Mobile clients

    +

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

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

    + Could not load mobile clients +

    +

    {devicesState.error}

    +
    + +
    + ) : null} + + {isInitialLoad ? ( + + ) : hasErrorWithoutData ? null : devices.length > 0 ? ( +
      + {devices.map((device) => ( + + ))} +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index d3f906ef414..45477ee1b7e 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,8 +1,9 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon } from "lucide-react"; +import { LogInIcon, SmartphoneIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; export function T3ConnectSidebarSignIn() { @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() { userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent", }, }} - /> + > + } + url="mobile-clients" + > + + + ); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e693c7b15b0..5012986ff45 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -118,6 +118,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, + updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, } from "~/cloud/linkEnvironmentAtoms"; import { authEnvironment } from "~/state/auth"; import { environmentCatalog } from "~/connection/catalog"; @@ -1457,7 +1458,7 @@ function SavedBackendListRow({ : null; const metadataBits = [ sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, - environment.relayManaged ? "T3 Cloud" : null, + environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); return ( @@ -1575,7 +1576,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 message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; const traceId = findErrorTraceId(cause); - console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause }); + console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); toastManager.add({ type: "error", - title: "Could not update T3 Cloud", + title: "Could not update T3 Connect", description: message, data: traceId ? { @@ -1643,9 +1649,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b return; } if (enabled && !tokenResult.value) { - reportUpdateFailure( - new Error("Sign in from T3 Cloud settings before linking this environment."), - ); + reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); setIsUpdating(false); return; } @@ -1680,38 +1684,95 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b toastManager.add({ type: "success", - title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", + title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", description: enabled - ? "This environment is available through T3 Cloud." - : "This environment is no longer available through T3 Cloud.", + ? "This environment is available through T3 Connect." + : "This environment is no longer available through T3 Connect.", }); setIsUpdating(false); }; + + const updatePublishAgentActivity = async (enabled: boolean) => { + const target = primaryCloudLinkState.target; + if (!target) { + reportUpdateFailure(new Error("Local environment is not ready yet.")); + return; + } + + setIsUpdatingPreference(true); + setOperationError(null); + const updateResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: enabled, + }); + if (updateResult._tag === "Failure") { + if (!isAtomCommandInterrupted(updateResult)) { + reportUpdateFailure(squashAtomCommandFailure(updateResult)); + } + setIsUpdatingPreference(false); + return; + } + + primaryCloudLinkState.refresh(); + toastManager.add({ + type: "success", + title: enabled ? "Agent activity enabled" : "Agent activity disabled", + description: enabled + ? "This environment can publish agent activity to your mobile clients." + : "This environment will stop publishing agent activity.", + }); + setIsUpdatingPreference(false); + }; const disabledReason = !isSignedIn - ? "Sign in from T3 Cloud settings to manage this environment." + ? "Sign in to T3 Connect to manage this environment." : !canManageRelay - ? "Your session does not have permission to manage T3 Cloud access." + ? "Your session does not have permission to manage T3 Connect access." : null; const linked = primaryCloudLinkState.data?.linked ?? false; return ( - void updateLink(enabled)} + <> + void updateLink(enabled)} + /> + } + /> + {linked ? ( + void updatePublishAgentActivity(enabled)} + /> + } /> - } - /> + ) : null} + ); } @@ -1729,7 +1790,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable No saved remote environments {cloudEnabled - ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." + ? "Click “Add environment” to pair another environment, or connect one from T3 Connect." : "Click “Add environment” to pair another environment."} @@ -1794,7 +1855,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ toastManager.add({ type: "success", title: "Environment connected", - description: `${environment.label} is available through T3 Cloud.`, + description: `${environment.label} is available through T3 Connect.`, }); return; } @@ -1803,9 +1864,9 @@ function ConfiguredCloudRemoteEnvironmentRows({ } const cause = squashAtomCommandFailure(result); const message = - cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment."; + cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; const traceId = findErrorTraceId(cause); - console.error("[t3-cloud] Could not connect environment", { message, traceId, cause }); + console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); toastManager.add({ type: "error", title: "Could not connect environment", From 92e54fb96c5e24c45fc0fa7066a3a4d38294dfc7 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:21:06 +0200 Subject: [PATCH 251/257] [codex] fix: guard DPoP fallback URL construction (#3503) Co-authored-by: Codex Co-authored-by: Julius Marminge --- apps/server/src/auth/dpop.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 87dc0c263e2..f19984eb369 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -3,7 +3,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; -import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Option from "effect/Option"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { ServerAuthDpopReplayKeyCalculationError, @@ -13,22 +14,6 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -function firstHeaderValue(value: string | undefined): string | undefined { - const first = value?.split(",")[0]?.trim(); - return first && first.length > 0 ? first : undefined; -} - -export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string { - try { - return new URL(request.originalUrl).href; - } catch { - const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1"; - const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]); - const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http"; - return new URL(request.originalUrl, `${proto}://${host}`).href; - } -} - export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError => @@ -48,11 +33,17 @@ export const verifyRequestDpopProof = (input: { }) => Effect.gen(function* () { const proof = input.request.headers.dpop; + const url = HttpServerRequest.toURL(input.request); + if (Option.isNone(url)) { + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: "Invalid DPoP request URL.", + }); + } const now = yield* DateTime.now; const result = verifyDpopProof({ proof, method: input.request.method, - url: requestAbsoluteUrl(input.request), + url: url.value.href, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), From 6672a1d21060c6c7b927726b6b91fbfe8c8337f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 16:59:56 -0700 Subject: [PATCH 252/257] Bump Clerk packages and refresh lockfile (#3511) --- pnpm-lock.yaml | 148 ++++++++++++++++++++++---------------------- pnpm-workspace.yaml | 14 ++--- 2 files changed, 81 insertions(+), 81 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192723b7663..61fa9c92146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,13 +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/backend': 3.8.3-snapshot.v20260622234151 + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151 + '@clerk/electron': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 + '@clerk/expo': 3.5.3-snapshot.v20260622234151 + '@clerk/react': 6.11.0-snapshot.v20260622234151 + '@clerk/shared': 4.21.0-snapshot.v20260622234151 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' @@ -113,11 +113,11 @@ 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) + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': - specifier: 0.0.1-snapshot.v20260619001138 - version: 0.0.1-snapshot.v20260619001138 + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151 '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -196,8 +196,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: 3.4.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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 3.5.3-snapshot.v20260622234151 + version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -483,11 +483,11 @@ importers: 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/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) + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: 6.10.4-snapshot.v20260619001138 - version: 6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.11.0-snapshot.v20260622234151 + version: 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -637,8 +637,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - 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) + specifier: 3.8.3-snapshot.v20260622234151 + version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) @@ -1532,43 +1532,43 @@ packages: resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.8.2-snapshot.v20260619001138': - resolution: {integrity: sha512-nT6M7rKTuvoDnSZwO3Th2NMjcWZy/0ZfXYyqd/o/lFpUFcQO0J4fM2e2wF9kNCl99EPvDQQUAR8APNNy/j40rg==} + '@clerk/backend@3.8.3-snapshot.v20260622234151': + resolution: {integrity: sha512-B5goX0n/5pibc4dMQOfMmn4mPq7eqtrbNV20tOqO9qMPzFA+TKAj9SnB0oJvFsZAXKO6+/n16uYPbqeM2PN6CA==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138': - resolution: {integrity: sha512-BpRSi2QXdfR5nnzC7/YCCqK40m1M4A/rN5unau7QKHj6V7xChl2fOvxYjekpH+DEyw6NAe/2jdqQv35iv3T5oA==} + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151': + resolution: {integrity: sha512-mRNn6H8GbeEkcCIzZ03WZ9c1Uy8znf70okYmmeJKzK72gsdwnrxEfbu3DYE/5yRbX6lvL7ugWaNTT3DvPEbBzg==} engines: {node: '>=20.9.0'} - '@clerk/electron-passkeys-darwin-arm64@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-dbQ/0ZtfDQgYKCSzu3AMxTnGSrdZxulurZMT4Jpvin48Etc27PdcT7VvwOGIa7R+Ab8yMeaoLJbfwHTZv35F+Q==} + '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-NZjIVGitAf0yyQvs1WXIAYOle9RjGsExpfwje2U20POTnNueFy56M0g39UTFQXJ42saTZ0zauLD8sd0cHqpOjA==} cpu: [arm64] os: [darwin] - '@clerk/electron-passkeys-darwin-x64@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-g9tbni7yKIJ/Xpollm25Gf7YLyFP24VyqRHcGDnEsHC1tIffG41spjLr10NgsBoRvFLMlaQJMSrfpjVOpBhAjw==} + '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-gGAwinVoIxa9lefCJjhf5D6oA8uq1sXQ/JwqffkBPJrvczYyAv9FYuQ1/ihji3jTplC0/bpTuIrwBkBCzwj5ug==} cpu: [x64] os: [darwin] - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-mFDQ1vQ9dLIUrnjNGhIGxqyK0iiym919gYDPO3orYEcICdEiE8xZyEbCtKSVaeX6RWGJAcqFzCxbLVG6V+1k4Q==} + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-anBFktMTAF7of37nLQsqQuW489w563J75l4QolWtblKbrBjlwFsEif95uj3vlQj0qWVVRWpAcxD21oD6wdJcwQ==} cpu: [arm64] os: [win32] - '@clerk/electron-passkeys-win32-x64-msvc@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-IwkLw+d73bd7YJPRR8LAkqP42VIIpbJXCCbs5eVKbmam9M0vSUyeLWEtSiWUEypmLK13xv+7316K4GIA2tmDGQ==} + '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-Pq4FOklTJNpyMTJpxY+/gq6CukvWHFJfGYjuz985cPHup1OSsVmuO/GYil36vh7Qbe8vDivT91wNe73DbEOcVw==} cpu: [x64] os: [win32] - '@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-diU5Q9Nx+30mesrLFOmr0OCnmxE0ogUHXktZBTQx2nQvZb/n2UGOpGNLbY1p9edKMcSc5LfJEsQ8NogfLBBvPg==} + '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-UvBweTg9+FCbygxARV7RvJ4/lcQgLN+gJC6Lj++cLmbxpgRwRAgR9xXRlKV6dVg1p5k81a4DcFNMu/oR6zBdlQ==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-wrBEdMMRqhMF4a7aQpZaBXKJfONIpaYLcgBl0m1b2r+Xg4yuZ47YUoDxhI2Ksvbx2KQPd4i9HP0enL6gEcoqfA==} + '@clerk/electron@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-T0LUJeAPaAZxpk13Q14b9LSVKKio/X/zFo67hgdr35MaOChsn4T6lQ/rEL7laScQBduskcs1yyjr3e9ndy5ojg==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 electron: '>=28' electron-store: ^8.2.0 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -1581,11 +1581,11 @@ packages: react-dom: optional: true - '@clerk/expo@3.4.8-snapshot.v20260619001138': - resolution: {integrity: sha512-E6q4p5ded45aO3y/+f7APfy5pFj2Y/BEpe/1gzdr6UGxj9kJxkiZQV29hcpjYEFMEJK60f7XbOHZTQEZRB5OwQ==} + '@clerk/expo@3.5.3-snapshot.v20260622234151': + resolution: {integrity: sha512-qeKTJYA7cTe5oCmRVCT4A2QEPRp2af0ox0VdXzIhWRqRVFNF5bnsZRNKE2e4QnsC16NYLsGHLR5uQGC15qBiug==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/expo-passkeys': 1.1.8-snapshot.v20260619001138 + '@clerk/expo-passkeys': 1.1.9-snapshot.v20260622234151 expo: '>=53 <57' expo-apple-authentication: '>=7.0.0' expo-auth-session: '>=5' @@ -1596,7 +1596,7 @@ packages: expo-web-browser: '>=12.5.0' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-native: '>=0.73' + react-native: '>=0.75' peerDependenciesMeta: '@clerk/expo-passkeys': optional: true @@ -1617,15 +1617,15 @@ packages: react-dom: optional: true - '@clerk/react@6.10.4-snapshot.v20260619001138': - resolution: {integrity: sha512-Z7Otjly14SoxadMmk8d9ZdbaXU0me9B1zGdCtnNIcQ7X2GCbeyKm/lOM27IzWYXZKO6o+sXlqsq9A9tcxet5nA==} + '@clerk/react@6.11.0-snapshot.v20260622234151': + resolution: {integrity: sha512-yF4jQFJqEHAqZCpOtjQ/Kg9yqZlEG6vW2vmVR5kVwgESkpOR1KPJIEMU8o3b6W86RKQai4lt3CWtY+o7CJsDyw==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.19.2-snapshot.v20260619001138': - resolution: {integrity: sha512-NAIz0L6+CaRrYn0DoXclUP1R0g6C2ljOzHogQ3rx9/SWUVpfaYX2lxPhURU89xvPoOGBZQb2xwk6xFh/7cjJfQ==} + '@clerk/shared@4.21.0-snapshot.v20260622234151': + resolution: {integrity: sha512-OYu+hO0GHiHwYTwSFe/GCHmuQlyTHyNa7fJ8vrmtUyML09mf+dayRFeUnxgkwOYhGriyq40t1zxdVx53Td73xg==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -10871,18 +10871,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.8.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10897,9 +10897,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10914,43 +10914,43 @@ snapshots: - react - react-dom - '@clerk/electron-passkeys-darwin-arm64@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-darwin-x64@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-win32-x64-msvc@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': 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-passkeys-darwin-arm64': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-darwin-x64': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-win32-x64-msvc': 0.0.2-snapshot.v20260622234151 - '@clerk/electron@0.0.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@0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.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) + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) electron: 41.5.0 react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@3.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)': + '@clerk/expo@3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@clerk/clerk-js': 6.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) + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-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 @@ -10965,21 +10965,21 @@ snapshots: expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -10989,7 +10989,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03960dfbbdd..8096d3a0a3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,13 +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 + "@clerk/backend": 3.8.3-snapshot.v20260622234151 + "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151 + "@clerk/electron": 0.0.2-snapshot.v20260622234151 + "@clerk/electron-passkeys": 0.0.2-snapshot.v20260622234151 + "@clerk/expo": 3.5.3-snapshot.v20260622234151 + "@clerk/react": 6.11.0-snapshot.v20260622234151 + "@clerk/shared": 4.21.0-snapshot.v20260622234151 "@effect/atom-react": 4.0.0-beta.78 "@effect/openapi-generator": 4.0.0-beta.78 "@effect/platform-bun": 4.0.0-beta.78 From 3083d712eefb8009f7366ba53040eb2d477bfa33 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:33:01 +0200 Subject: [PATCH 253/257] [codex] fix: clarify Cursor CLI setup error (#3519) Co-authored-by: Codex --- .../provider/Layers/CursorProvider.test.ts | 54 +++++++++++++++---- .../src/provider/Layers/CursorProvider.ts | 19 ++++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 60a7312eea3..78f62ac2123 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect, it } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; import type { CursorSettings } from "@t3tools/contracts"; @@ -24,7 +25,11 @@ import { } from "./CursorProvider.ts"; const runNode = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + >, ): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { @@ -293,6 +298,18 @@ const baseCursorSettings: CursorSettings = { apiEndpoint: "", customModels: [], }; +const cursorAcpDiscoveryFailedMessage = [ + "Cursor ACP model discovery failed.", + "Cursor CLI setup may be incomplete; install or enable the Cursor CLI, restart T3 Code, and try again.", + "See https://cursor.com/docs/cli/installation.", + "Check server logs for ACP details.", +].join(" "); +const missingCursorBinaryPath = "/definitely/not/installed/t3-cursor-agent"; +const cursorCliCommandMissingMessage = [ + `Cursor CLI command \`${missingCursorBinaryPath}\` was not found.`, + `Install or enable the Cursor CLI, make sure \`${missingCursorBinaryPath}\` is on PATH, then restart T3 Code.`, + "See https://cursor.com/docs/cli/installation.", +].join(" "); describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { @@ -338,12 +355,11 @@ describe("buildCursorProviderSnapshot", () => { auth: { status: "unauthenticated" }, message: "Cursor Agent is not authenticated. Run `agent login` and try again.", }, - discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.", + discoveryWarning: cursorAcpDiscoveryFailedMessage, }), ).toMatchObject({ status: "error", - message: - "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.", + message: `Cursor Agent is not authenticated. Run \`agent login\` and try again. ${cursorAcpDiscoveryFailedMessage}`, models: [ { slug: "claude-sonnet-4-6", @@ -411,10 +427,28 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { }); describe("checkCursorProviderStatus", () => { + it("reports the install docs when the Cursor CLI command is missing", async () => { + const provider = await runNode( + checkCursorProviderStatus({ + enabled: true, + binaryPath: missingCursorBinaryPath, + apiEndpoint: "", + customModels: [], + }), + ); + + expect(provider).toMatchObject({ + installed: false, + status: "error", + auth: { status: "unknown" }, + message: cursorCliCommandMissingMessage, + }); + }); + it("passes the injected environment to ACP model discovery", async () => { const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture()); - const provider = await Effect.runPromise( + const provider = await runNode( checkCursorProviderStatus( { enabled: true, @@ -426,7 +460,7 @@ describe("checkCursorProviderStatus", () => { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }, - ).pipe(Effect.provide(NodeServices.layer)), + ), ); expect(provider.models.map((model) => model.slug)).toEqual([ @@ -443,13 +477,13 @@ describe("discoverCursorModelsViaAcp", () => { it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); - const models = await Effect.runPromise( + const models = await runNode( discoverCursorModelsViaAcp({ enabled: true, binaryPath: wrapperPath, apiEndpoint: "", customModels: [], - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.scoped), ); expect(models.map((model) => model.slug)).toEqual([ @@ -465,13 +499,13 @@ describe("discoverCursorModelsViaAcp", () => { makeExitLogFixture("cursor-provider-exit-log-"), ); - await Effect.runPromise( + await runNode( discoverCursorModelsViaAcp({ enabled: true, binaryPath: wrapperPath, apiEndpoint: "", customModels: [], - }).pipe(Effect.provide(NodeServices.layer)), + }), ); const exitLog = await runNode(waitForFileContent(exitLogPath)); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index ff96ece9349..cd9b93a4734 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -62,6 +62,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; +const CURSOR_CLI_INSTALLATION_DOCS_URL = "https://cursor.com/docs/cli/installation"; +const CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE = [ + "Cursor ACP model discovery failed.", + "Cursor CLI setup may be incomplete; install or enable the Cursor CLI, restart T3 Code, and try again.", + `See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`, + "Check server logs for ACP details.", +].join(" "); export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { _meta: { parameterizedModelPicker: true, @@ -608,6 +615,14 @@ function joinProviderMessages(...messages: ReadonlyArray): s return parts.length > 0 ? parts.join(" ") : undefined; } +function buildCursorCliCommandMissingMessage(binaryPath: string): string { + return [ + `Cursor CLI command \`${binaryPath}\` was not found.`, + `Install or enable the Cursor CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`, + `See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`, + ].join(" "); +} + export function buildCursorProviderSnapshot(input: { readonly checkedAt: string; readonly cursorSettings: CursorSettings; @@ -1020,7 +1035,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( status: "error", auth: { status: "unknown" }, message: isCommandMissingCause(error) - ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." + ? buildCursorCliCommandMissingMessage(cursorSettings.binaryPath) : "Failed to execute Cursor Agent CLI health check.", }, }); @@ -1079,7 +1094,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( yield* Effect.logWarning("Cursor ACP model discovery failed", { errorTag: causeErrorTag(discoveryExit.cause), }); - discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; + discoveryWarning = CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE; } else if (Option.isNone(discoveryExit.value)) { discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; } else if (discoveryExit.value.value.length === 0) { From 4abf8b46c591ae2b36aa29598c388100df651411 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:33:23 +0200 Subject: [PATCH 254/257] [codex] fix: ignore stale shell reducer events (#3517) Co-authored-by: Codex --- .../src/state/shellReducer.test.ts | 20 +++++++++++++++++++ .../client-runtime/src/state/shellReducer.ts | 2 ++ 2 files changed, 22 insertions(+) diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 4689c1408f7..a069460e63c 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -44,6 +44,26 @@ const stubThread = { } as const; describe("applyShellStreamEvent", () => { + it("ignores stale project upserts without mutating the snapshot", () => { + const snapshotWithProject: OrchestrationShellSnapshot = { + ...baseSnapshot, + snapshotSequence: 4, + projects: [stubProject], + }; + + for (const sequence of [3, 4]) { + const next = applyShellStreamEvent(snapshotWithProject, { + kind: "project-upserted", + sequence, + project: { ...stubProject, title: "Stale Title" }, + }); + + expect(next).toBe(snapshotWithProject); + expect(next.snapshotSequence).toBe(4); + expect(next.projects[0]?.title).toBe("Test Project"); + } + }); + describe("project-upserted", () => { it("adds a new project", () => { const event: OrchestrationShellStreamEvent = { diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 71c8a6b0eb3..3d3b22a1289 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -13,6 +13,8 @@ export function applyShellStreamEvent( snapshot: OrchestrationShellSnapshot, event: OrchestrationShellStreamEvent, ): OrchestrationShellSnapshot { + if (event.sequence <= snapshot.snapshotSequence) return snapshot; + switch (event.kind) { case "project-upserted": { const projects = snapshot.projects.some((p) => p.id === event.project.id) From c6c64918f8c582cc8b12de155d6c9aae12037147 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:47:26 -0700 Subject: [PATCH 255/257] Reduce ChatMarkdown settings rerenders (#3536) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge --- apps/web/src/components/ChatMarkdown.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 47604d23ca2..b04e98a8a60 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -54,7 +54,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { useClientSettings } from "../hooks/useSettings"; +import { getClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, @@ -293,10 +293,14 @@ function getHighlighterPromise(language: string): Promise { return promise; } +function readInitialWordWrapSetting(): boolean { + return getClientSettings().wordWrap; +} + function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); - const [expanded, setExpanded] = useState(useClientSettings((settings) => settings.wordWrap)); + const [expanded, setExpanded] = useState(readInitialWordWrapSetting); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; @@ -526,7 +530,7 @@ function MarkdownCodeBlock({ children: ReactNode; }) { const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(useClientSettings((settings) => settings.wordWrap)); + const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; From a4964b3b363dc515fad04458e5936cd32053c659 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:04:20 +0200 Subject: [PATCH 256/257] [codex] fix: show standalone element-pick context (#3527) Co-authored-by: Codex --- .../components/chat/MessagesTimeline.test.tsx | 27 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 8 ++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c2130381af6..54ad25df7b7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -193,6 +193,33 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Show full message"); }, 20_000); + it("renders chips for standalone element-pick context messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ", + "- (Button.tsx:12):", + " url: https://example.com/dashboard", + " selector: button.submit", + " source: /repo/src/Button.tsx:12:5", + " html:", + ' ', + "", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("SubmitButton"); + expect(markup).not.toContain("<element_context"); + expect(markup).not.toContain(" { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 88cbecb9bec..69c0f2d0260 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -448,6 +448,10 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; @@ -495,9 +499,9 @@ function UserTimelineRow({ row }: { row: Extract ))} - {elementContextState.contexts.length > 0 ? ( + {elementContexts.length > 0 ? (
    - {elementContextState.contexts.map((context) => ( + {elementContexts.map((context) => ( Date: Wed, 24 Jun 2026 15:39:17 -0700 Subject: [PATCH 257/257] [codex] Upgrade Legend List chat scrolling (#3545) Co-authored-by: Julius Marminge --- apps/mobile/package.json | 6 +- .../src/features/threads/ThreadComposer.tsx | 7 +- .../features/threads/ThreadDetailScreen.tsx | 114 +++++-- .../src/features/threads/ThreadFeed.tsx | 184 ++++------- apps/mobile/src/lib/threadActivity.test.ts | 12 +- apps/mobile/src/lib/threadActivity.ts | 20 +- apps/mobile/src/lib/threadFeedLayout.test.ts | 32 -- apps/mobile/src/lib/threadFeedLayout.ts | 22 -- .../src/state/use-thread-composer-state.ts | 33 +- apps/web/package.json | 2 +- .../BranchToolbarBranchSelector.tsx | 11 +- apps/web/src/components/ChatView.tsx | 292 +++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 30 +- .../components/chat/MessagesTimeline.test.tsx | 52 +++- .../src/components/chat/MessagesTimeline.tsx | 39 +-- .../components/chat/ModelPickerContent.tsx | 2 +- apps/web/src/index.css | 55 +++- packages/shared/package.json | 4 + packages/shared/src/chatList.test.ts | 42 +++ packages/shared/src/chatList.ts | 33 ++ pnpm-lock.yaml | 28 +- 21 files changed, 586 insertions(+), 434 deletions(-) delete mode 100644 apps/mobile/src/lib/threadFeedLayout.test.ts delete mode 100644 apps/mobile/src/lib/threadFeedLayout.ts create mode 100644 packages/shared/src/chatList.test.ts create mode 100644 packages/shared/src/chatList.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ddf5b2a0250..963cefd6b7c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -44,7 +44,7 @@ "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", - "@legendapp/list": "3.0.0-beta.44", + "@legendapp/list": "3.2.0", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", @@ -93,9 +93,9 @@ "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-image-viewing": "^0.2.2", - "react-native-keyboard-controller": "1.21.6", + "react-native-keyboard-controller": "1.21.7", "react-native-nitro-markdown": "^0.5.0", - "react-native-nitro-modules": "^0.35.4", + "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 0050eb923be..75991cae885 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,6 +1,7 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentId, + MessageId, ModelSelection, OrchestrationThreadShell, ProviderInteractionMode, @@ -91,7 +92,7 @@ export interface ThreadComposerProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: () => Promise; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -447,9 +448,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(() => { - void onSendMessage().then(() => { - inputRef.current?.blur(); - }); + void onSendMessage(); }, [onSendMessage]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 624e8fe14fe..62d1bce1157 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,7 +1,10 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; +import type { LegendListRef } from "@legendapp/list/react-native"; import type { ApprovalRequestId, EnvironmentId, + MessageId, ModelSelection, OrchestrationThreadShell, ProviderApprovalDecision, @@ -13,8 +16,8 @@ import type { import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { View, type GestureResponderEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -25,7 +28,7 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { LayoutVariant } from "../../lib/layout"; -import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, PendingUserInput, @@ -73,7 +76,7 @@ export interface ThreadDetailScreenProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: () => Promise; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -206,25 +209,33 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; - const composerRef = useRef(null); + const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const composerEditorRef = useRef(null); + const composerOverlayRef = useRef(null); + const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); + const selectedThreadKeyRef = useRef(selectedThreadKey); + const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); + const [anchorMessageId, setAnchorMessageId] = useState(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); + const contentPresentationKind = props.contentPresentation.kind; + const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; - const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; - const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); + const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; + const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( + listRef, + composerOverlayRef, + estimatedOverlayHeight, + ); + const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const feedBottomInset = resolveThreadFeedBottomInset({ - estimatedOverlayHeight, - measuredOverlayHeight, - gap: 8, - }); const selectedProviderSkills = useMemo( () => props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) @@ -254,15 +265,67 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread [completeDrawerGesture, isSplitLayout], ); - const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => { - const nextHeight = Math.ceil(event.nativeEvent.layout.height); - setMeasuredOverlayHeight((current) => - Math.abs(current - nextHeight) > 1 ? nextHeight : current, - ); - }, []); + useLayoutEffect(() => { + selectedThreadKeyRef.current = selectedThreadKey; + }, [selectedThreadKey]); + + useEffect(() => { + setAnchorMessageId(null); + lastScrolledAnchorMessageIdRef.current = null; + freeze.set(false); + }, [freeze, selectedThreadKey]); + + useEffect(() => { + if ( + anchorMessageId === null || + lastScrolledAnchorMessageIdRef.current === anchorMessageId || + contentPresentationKind !== "ready" || + !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) + ) { + return; + } + + const targetThreadKey = selectedThreadKey; + const frame = requestAnimationFrame(() => { + if (selectedThreadKeyRef.current !== targetThreadKey) { + return; + } + lastScrolledAnchorMessageIdRef.current = anchorMessageId; + void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { + if ( + selectedThreadKeyRef.current !== targetThreadKey || + lastScrolledAnchorMessageIdRef.current !== anchorMessageId + ) { + return; + } + lastScrolledAnchorMessageIdRef.current = null; + freeze.set(false); + }); + }); + return () => cancelAnimationFrame(frame); + }, [ + anchorMessageId, + freeze, + contentPresentationKind, + selectedThreadFeed, + scrollMessageToEnd, + selectedThreadKey, + ]); + + const handleSendMessage = useCallback(async () => { + const targetThreadKey = selectedThreadKey; + const messageId = await props.onSendMessage(); + if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { + return messageId; + } + + setAnchorMessageId(messageId); + composerEditorRef.current?.blur(); + return messageId; + }, [props.onSendMessage, selectedThreadKey]); const collapseComposer = useCallback(() => { - composerRef.current?.blur(); + composerEditorRef.current?.blur(); }, []); const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { @@ -315,10 +378,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread contentPresentation={props.contentPresentation} agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} + listRef={listRef} + freeze={freeze} + anchorMessageId={anchorMessageId} + contentInsetEndAdjustment={contentInsetEndAdjustment} contentTopInset={headerHeight} - contentBottomInset={feedBottomInset} + contentBottomInset={estimatedOverlayHeight} layoutVariant={layoutVariant} - composerExpanded={composerExpanded} skills={selectedProviderSkills} /> @@ -332,7 +398,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} offset={{ closed: 0, opened: 0 }} > - + {props.activeWorkStartedAt ? ( ) : null} @@ -361,7 +427,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} ; + readonly freeze: SharedValue; + readonly anchorMessageId: MessageId | null; + readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly layoutVariant?: LayoutVariant; - readonly composerExpanded?: boolean; readonly skills?: ReadonlyArray; } @@ -812,30 +821,6 @@ function renderFeedEntry( ); } - if (entry.type === "queued-message") { - return ( - - - - {entry.queuedMessage.text} - - {entry.queuedMessage.attachments.length > 0 ? ( - - {entry.queuedMessage.attachments.length} image - {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached - - ) : null} - - - {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} - - - ); - } - return ( (null); const copyFeedbackTimeoutRef = useRef | null>(null); - const scrollFrameRef = useRef(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); - const suppressAutoFollowRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); - const isNearEndRef = useRef(true); - const initialScrollReadyRef = useRef(false); - const lastContentHeightRef = useRef(0); const { width: viewportWidth } = useWindowDimensions(); + const [foldToggleSettling, setFoldToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1214,6 +1194,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), [expandedTurnIds, props.feed, props.latestTurn], ); + const anchoredEndSpace = useMemo( + () => + resolveChatListAnchoredEndSpace( + presentedFeed, + props.anchorMessageId, + (entry) => (entry.type === "message" ? entry.id : null), + { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET }, + ), + [presentedFeed, props.anchorMessageId, topContentInset], + ); const terminalAssistantMessageIds = useMemo(() => { const terminalIdsByTurn = new Map(); for (const entry of props.feed) { @@ -1229,54 +1219,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? props.latestTurn.turnId : null; - const scrollToEnd = useCallback(() => { - if (scrollFrameRef.current !== null) { - return; - } - scrollFrameRef.current = requestAnimationFrame(() => { - scrollFrameRef.current = null; - listRef.current?.scrollToEnd({ animated: false }); - }); - }, []); - - const onListScroll = useCallback( - (event: NativeSyntheticEvent | NativeScrollEvent) => { - const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; - const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; - isNearEndRef.current = isThreadFeedNearEnd( - { - contentHeight: contentSize.height, - viewportHeight: layoutMeasurement.height, - offsetY: contentOffset.y, - bottomInset: contentInset.bottom, - }, - THREAD_FEED_END_THRESHOLD, - ); - }, - [], - ); - - const onListContentSizeChange = useCallback( - (_width: number, height: number) => { - const contentGrew = height > lastContentHeightRef.current + 0.5; - lastContentHeightRef.current = height; - - if ( - initialScrollReadyRef.current && - contentGrew && - isNearEndRef.current && - !suppressAutoFollowRef.current - ) { - scrollToEnd(); - } - }, - [scrollToEnd], - ); - - const onListLoad = useCallback(() => { - initialScrollReadyRef.current = true; - }, []); - useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; @@ -1308,9 +1250,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } - if (scrollFrameRef.current !== null) { - cancelAnimationFrame(scrollFrameRef.current); - } if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1358,7 +1297,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onToggleTurnFold = useCallback((turnId: TurnId) => { - suppressAutoFollowRef.current = true; + setFoldToggleSettling(true); if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1376,7 +1315,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); foldSettleFrameRef.current = requestAnimationFrame(() => { foldSettleSecondFrameRef.current = requestAnimationFrame(() => { - suppressAutoFollowRef.current = false; + setFoldToggleSettling(false); foldSettleFrameRef.current = null; foldSettleSecondFrameRef.current = null; }); @@ -1458,59 +1397,60 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); } - if (props.feed.length === 0) { - return ( - - ); - } - return ( <> - `${entry.type}:${entry.id}`} + keyExtractor={(entry) => entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } keyboardShouldPersistTaps="always" keyboardDismissMode="none" + keyboardLiftBehavior="whenAtEnd" estimatedItemSize={180} initialScrollAtEnd - onContentSizeChange={onListContentSizeChange} - onLoad={onListLoad} - onScroll={onListScroll} - scrollEventThrottle={16} ListHeaderComponent={} contentContainerStyle={{ paddingTop: 12, - paddingBottom: bottomContentInset, paddingHorizontal: horizontalPadding, }} /> + {props.feed.length === 0 ? ( + + + + ) : null} { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); expect(feed).toMatchObject([ { type: "activity-group", @@ -144,7 +144,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const group = feed[0]; expect(group).toMatchObject({ @@ -209,7 +209,7 @@ describe("buildThreadFeed", () => { ], }); - const group = buildThreadFeed(thread, [], null)[0]; + const group = buildThreadFeed(thread)[0]; expect(group).toMatchObject({ type: "activity-group" }); if (!group || group.type !== "activity-group") { return; @@ -271,7 +271,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); expect(collapsed[0]).toMatchObject({ @@ -359,7 +359,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ turnId: firstTurnId, @@ -399,7 +399,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); expect(feed[0]).toMatchObject({ type: "activity-group", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index bef46e46e6e..f6008057a0e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,6 +1,5 @@ import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; import type { - MessageId, OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, @@ -10,7 +9,6 @@ import type { } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import type { QueuedThreadMessage } from "../state/thread-outbox-model"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -88,13 +86,6 @@ type RawThreadFeedEntry = readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; } - | { - readonly type: "queued-message"; - readonly id: string; - readonly createdAt: string; - readonly queuedMessage: QueuedThreadMessage; - readonly sending: boolean; - } | { readonly type: "activity"; readonly id: string; @@ -104,7 +95,7 @@ type RawThreadFeedEntry = }; export type ThreadFeedEntry = - | Extract + | Extract | { readonly type: "activity-group"; readonly id: string; @@ -1255,8 +1246,6 @@ export function buildPendingUserInputAnswers( export function buildThreadFeed( thread: OrchestrationThread, - queuedMessages: ReadonlyArray, - dispatchingQueuedMessageId: MessageId | null, options?: { readonly loadedMessages?: ReadonlyArray; }, @@ -1273,13 +1262,6 @@ export function buildThreadFeed( createdAt: message.createdAt, message, })), - ...queuedMessages.map((queuedMessage) => ({ - type: "queued-message", - id: queuedMessage.messageId, - createdAt: queuedMessage.createdAt, - queuedMessage, - sending: queuedMessage.messageId === dispatchingQueuedMessageId, - })), ...workLogEntries .filter((entry) => { if (options?.loadedMessages === undefined) { diff --git a/apps/mobile/src/lib/threadFeedLayout.test.ts b/apps/mobile/src/lib/threadFeedLayout.test.ts deleted file mode 100644 index 73f113eac38..00000000000 --- a/apps/mobile/src/lib/threadFeedLayout.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - isThreadFeedNearEnd, - resolveThreadFeedBottomInset, - threadFeedDistanceFromEnd, -} from "./threadFeedLayout"; - -describe("thread feed layout", () => { - it("accounts for the bottom inset when measuring distance from the end", () => { - const metrics = { - contentHeight: 900, - viewportHeight: 600, - offsetY: 380, - bottomInset: 100, - }; - - expect(threadFeedDistanceFromEnd(metrics)).toBe(20); - expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); - expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); - }); - - it("does not double count chrome already included in the measured composer overlay", () => { - expect( - resolveThreadFeedBottomInset({ - estimatedOverlayHeight: 162, - measuredOverlayHeight: 182, - gap: 8, - }), - ).toBe(190); - }); -}); diff --git a/apps/mobile/src/lib/threadFeedLayout.ts b/apps/mobile/src/lib/threadFeedLayout.ts deleted file mode 100644 index de7946f866d..00000000000 --- a/apps/mobile/src/lib/threadFeedLayout.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface ThreadFeedScrollMetrics { - readonly contentHeight: number; - readonly viewportHeight: number; - readonly offsetY: number; - readonly bottomInset: number; -} - -export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { - return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; -} - -export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { - return threadFeedDistanceFromEnd(metrics) <= threshold; -} - -export function resolveThreadFeedBottomInset(input: { - readonly estimatedOverlayHeight: number; - readonly measuredOverlayHeight: number; - readonly gap: number; -}): number { - return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; -} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 0b8cba16e16..90831f8437a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -40,7 +40,6 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; 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; @@ -77,7 +76,6 @@ export function useThreadComposerState() { const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); - const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); useEffect(() => { @@ -91,17 +89,9 @@ export function useThreadComposerState() { () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); - const selectedThreadFeed = useMemo( - () => - selectedThreadDetail - ? buildThreadFeed( - selectedThreadDetail, - selectedThreadQueuedMessages, - dispatchingQueuedMessageId, - ) - : [], - [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages], + () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []), + [selectedThreadDetail], ); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; @@ -125,7 +115,6 @@ export function useThreadComposerState() { }; }, [selectedThreadDetail, selectedThreadShell]); - const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; const activeWorkStartedAt = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread) { @@ -135,14 +124,9 @@ export function useThreadComposerState() { return deriveActiveWorkStartedAt( selectedThread.latestTurn, selectedThreadSessionActivity, - queuedSendStartedAt, + null, ); - }, [ - queuedSendStartedAt, - selectedThreadDetail, - selectedThreadSessionActivity, - selectedThreadShell, - ]); + }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); const activeThreadBusy = !!selectedThread && @@ -150,7 +134,7 @@ export function useThreadComposerState() { const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { - return; + return null; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); @@ -159,15 +143,16 @@ export function useThreadComposerState() { const text = draft.text.trim(); const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { - return; + return null; } const metadata = makeQueuedMessageMetadata(); + const messageId = MessageId.make(metadata.messageId); try { await enqueueThreadOutboxMessage({ environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, - messageId: MessageId.make(metadata.messageId), + messageId, commandId: CommandId.make(metadata.commandId), text, attachments, @@ -177,10 +162,12 @@ export function useThreadComposerState() { createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); + return messageId; } catch (error) { setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); + return null; } }, [selectedThreadDetail, selectedThreadShell]); diff --git a/apps/web/package.json b/apps/web/package.json index 632e2d14395..484422f1a51 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,7 @@ "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.0.0-beta.44", + "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 7798f38e43e..e2ee24c3608 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -518,7 +518,7 @@ export function BranchToolbarBranchSelector({ return; } - branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); useEffect(() => { @@ -628,7 +628,7 @@ export function BranchToolbarBranchSelector({ if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; } - branchListRef.current?.scrollIndexIntoView?.({ + void branchListRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false, }); @@ -696,6 +696,13 @@ export function BranchToolbarBranchSelector({ ref={branchListRef} data={filteredBranchPickerItems} keyExtractor={(item) => item} + getItemType={(item) => + item === checkoutPullRequestItemValue + ? "checkout-pull-request" + : item === createBranchItemValue + ? "create-branch" + : "branch" + } renderItem={({ item, index }) => renderPickerItem(item, index)} estimatedItemSize={28} drawDistance={336} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44429614b44..9fb8d647b4e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -41,7 +41,17 @@ 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 { + lazy, + memo, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { @@ -1137,12 +1147,32 @@ function ChatViewContent(props: ChatViewProps) { LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); + const [composerOverlayElement, setComposerOverlayElement] = useState(null); + const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalUiOpenByThreadRef = useRef>({}); + useLayoutEffect(() => { + if (!composerOverlayElement) return; + + const updateHeight = () => { + const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); + setComposerOverlayHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + }; + + updateHeight(); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(updateHeight); + observer.observe(composerOverlayElement); + return () => observer.disconnect(); + }, [composerOverlayElement]); + const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); @@ -1254,6 +1284,14 @@ function ChatViewContent(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const [timelineAnchor, setTimelineAnchor] = useState<{ + readonly threadKey: string | null; + readonly messageId: MessageId | null; + }>({ threadKey: activeThreadKey, messageId: null }); + if (timelineAnchor.threadKey !== activeThreadKey) { + setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); + } + const timelineAnchorMessageId = timelineAnchor.messageId; const activeRightPanelKind = useRightPanelStore((state) => selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); @@ -3111,7 +3149,7 @@ function ChatViewContent(props: ChatViewProps) { // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. const scrollToEnd = useCallback((animated = false) => { - legendListRef.current?.scrollToEnd?.({ animated }); + void legendListRef.current?.scrollToEnd?.({ animated }); }, []); // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during @@ -3690,14 +3728,16 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - // Scroll to the current end *before* adding the optimistic message. - // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd - // automatically pins to the new item when the data changes. + // Sending always returns to the live edge. The new row becomes the + // anchored end-space target so it lands near the top while the response + // streams into the reserved space below it. isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - await legendListRef.current?.scrollToEnd?.({ animated: false }); - + setTimelineAnchor({ + threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageId: messageIdForSend, + }); setOptimisticUserMessages((existing) => [ ...existing, { @@ -3711,6 +3751,7 @@ function ChatViewContent(props: ChatViewProps) { streaming: false, }, ]); + void legendListRef.current?.scrollToEnd?.({ animated: false }); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { @@ -4722,7 +4763,7 @@ function ChatViewContent(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
    {/* Chat column */} -
    +
    {/* Messages Wrapper */}
    {/* Messages — LegendList handles virtualization and scrolling internally */} @@ -4747,12 +4788,17 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + anchorMessageId={timelineAnchorMessageId} + contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} /> {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && ( -
    +