From 6f344705681a9c41ff785dda2841570dab7a5dcf Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 20:03:50 +0200 Subject: [PATCH 1/2] feat: integrate Tim Smart feature set --- .github/workflows/ci.yml | 8 +- .github/workflows/deploy-relay.yml | 2 +- .github/workflows/mobile-eas-preview.yml | 2 +- .github/workflows/mobile-eas-production.yml | 2 +- .../workflows/mobile-showcase-screenshots.yml | 4 +- .github/workflows/release.yml | 26 +- .../src/backend/DesktopBackendPool.test.ts | 9 +- .../src/electron/ElectronDialog.test.ts | 95 +- apps/desktop/src/electron/ElectronDialog.ts | 254 ++-- .../src/electron/MacApplicationIcon.test.ts | 60 + .../src/electron/MacApplicationIcon.ts | 125 ++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 8 + apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/openWith.test.ts | 43 + apps/desktop/src/ipc/methods/openWith.ts | 47 + apps/desktop/src/main.ts | 7 +- apps/desktop/src/preload.ts | 4 + apps/desktop/src/preview/Manager.ts | 2 + .../settings/DesktopClientSettings.test.ts | 16 +- .../desktop/src/shell/DesktopOpenWith.test.ts | 225 ++++ apps/desktop/src/shell/DesktopOpenWith.ts | 323 +++++ .../src/window/DesktopApplicationMenu.test.ts | 1 + .../src/features/home/threadActionMessages.ts | 30 + .../src/features/home/useThreadListActions.ts | 115 +- .../home/worktreeCleanupPrompt.test.ts | 102 ++ .../features/home/worktreeCleanupPrompt.ts | 49 + .../threads/NewTaskDraftRouteScreen.tsx | 18 + .../features/threads/NewTaskDraftScreen.tsx | 30 + .../src/features/threads/ThreadComposer.tsx | 46 +- .../features/threads/ThreadDetailScreen.tsx | 2 + .../features/threads/ThreadRouteScreen.tsx | 19 + .../state/use-selected-thread-git-actions.ts | 137 ++- .../OrchestrationEngineHarness.integration.ts | 1 + .../checkpointing/CheckpointDiffQuery.test.ts | 10 + apps/server/src/git/GitManager.test.ts | 216 ++++ apps/server/src/git/GitManager.ts | 85 +- apps/server/src/git/GitWorkflowService.ts | 4 + apps/server/src/keybindings.test.ts | 1 + apps/server/src/observability/Metrics.ts | 4 + .../Layers/OrchestrationEngine.test.ts | 3 + .../Layers/ProjectionSnapshotQuery.test.ts | 240 ++++ .../Layers/ProjectionSnapshotQuery.ts | 77 ++ .../Layers/ProviderCommandReactor.test.ts | 859 +++++++++++--- .../Layers/ProviderCommandReactor.ts | 517 +++++++- .../Layers/WorktreeLifecycle.test.ts | 681 +++++++++++ .../orchestration/Layers/WorktreeLifecycle.ts | 395 +++++++ .../Services/ProjectionSnapshotQuery.ts | 41 + .../Services/WorktreeLifecycle.ts | 73 ++ .../Layers/ProjectionRepositories.test.ts | 81 ++ .../persistence/Layers/ProjectionThreads.ts | 26 + .../src/persistence/Layers/ProjectionTurns.ts | 28 + .../persistence/Services/ProjectionThreads.ts | 21 + .../persistence/Services/ProjectionTurns.ts | 9 + apps/server/src/processRunner.test.ts | 20 + apps/server/src/processRunner.ts | 3 +- .../project/ProjectSetupScriptRunner.test.ts | 2 + .../src/provider/DirenvEnvironment.test.ts | 236 ++++ apps/server/src/provider/DirenvEnvironment.ts | 249 ++++ .../src/provider/Drivers/ClaudeDriver.ts | 4 + .../src/provider/Drivers/CodexDriver.ts | 4 + .../src/provider/Drivers/CursorDriver.ts | 4 + .../server/src/provider/Drivers/GrokDriver.ts | 4 + .../src/provider/Drivers/OpenCodeDriver.ts | 4 + .../src/provider/Layers/ClaudeAdapter.test.ts | 42 +- .../src/provider/Layers/ClaudeAdapter.ts | 39 +- .../src/provider/Layers/CodexAdapter.test.ts | 117 ++ .../src/provider/Layers/CodexAdapter.ts | 33 +- .../src/provider/Layers/CursorAdapter.test.ts | 37 +- .../src/provider/Layers/CursorAdapter.ts | 108 +- .../server/src/provider/Layers/GrokAdapter.ts | 11 +- .../provider/Layers/OpenCodeAdapter.test.ts | 99 +- .../src/provider/Layers/OpenCodeAdapter.ts | 41 +- .../ProviderInstanceRegistryLive.test.ts | 7 +- .../provider/Layers/ProviderRegistry.test.ts | 558 ++++++++- .../provider/Layers/ProviderService.test.ts | 104 +- .../src/provider/Layers/ProviderService.ts | 133 ++- .../Layers/ProviderSessionReaper.test.ts | 3 + .../provider/ProviderRestartRecovery.test.ts | 73 ++ .../src/provider/ProviderRestartRecovery.ts | 103 ++ .../provider/acp/AcpJsonRpcConnection.test.ts | 54 + .../src/provider/acp/AcpSessionRuntime.ts | 8 +- .../src/provider/acp/CursorAcpSupport.test.ts | 15 + .../src/provider/acp/CursorAcpSupport.ts | 2 +- .../src/provider/acp/GrokAcpSupport.test.ts | 1 + .../server/src/provider/acp/GrokAcpSupport.ts | 1 + .../src/provider/opencodeRuntime.test.ts | 75 ++ apps/server/src/provider/opencodeRuntime.ts | 4 + apps/server/src/server.test.ts | 425 ++++++- apps/server/src/server.ts | 8 +- apps/server/src/serverRuntimeStartup.test.ts | 12 + .../src/sourceControl/BitbucketApi.test.ts | 1 + .../SourceControlRepositoryService.test.ts | 1 + apps/server/src/vcs/GitVcsDriver.ts | 10 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 95 +- apps/server/src/vcs/GitVcsDriverCore.ts | 107 +- apps/server/src/ws.ts | 104 +- apps/web/src/components/BranchToolbar.tsx | 10 +- .../BranchToolbarBranchSelector.tsx | 108 +- apps/web/src/components/ChatView.tsx | 230 +++- apps/web/src/components/CommandPalette.tsx | 13 + apps/web/src/components/GitActionsControl.tsx | 49 +- apps/web/src/components/ProjectFavicon.tsx | 10 +- apps/web/src/components/Sidebar.logic.test.ts | 86 ++ apps/web/src/components/Sidebar.logic.ts | 213 +++- apps/web/src/components/Sidebar.tsx | 62 + apps/web/src/components/SidebarV2.tsx | 51 +- .../src/components/ThreadStatusIndicators.tsx | 103 +- .../src/components/board/Board.logic.test.ts | 607 ++++++++++ apps/web/src/components/board/Board.logic.ts | 386 ++++++ .../src/components/board/BoardCard.test.tsx | 200 ++++ apps/web/src/components/board/BoardCard.tsx | 313 +++++ apps/web/src/components/board/BoardColumn.tsx | 55 + .../board/BoardDragClickGuard.test.ts | 48 + .../components/board/BoardDragClickGuard.ts | 47 + .../src/components/board/BoardDropZones.tsx | 78 ++ .../src/components/board/BoardScroll.test.ts | 157 +++ apps/web/src/components/board/BoardScroll.ts | 84 ++ apps/web/src/components/board/BoardView.tsx | 994 ++++++++++++++++ .../board/BoardWorktreeGroup.test.tsx | 100 ++ .../components/board/BoardWorktreeGroup.tsx | 123 ++ .../components/board/useBoardVcsStatuses.ts | 78 ++ apps/web/src/components/chat/ChatComposer.tsx | 79 +- apps/web/src/components/chat/OpenInPicker.tsx | 1035 +++++++++++++---- .../KeybindingsSettings.logic.test.ts | 1 + .../settings/KeybindingsSettings.logic.ts | 1 + .../components/settings/SettingsPanels.tsx | 31 + apps/web/src/components/ui/scroll-area.tsx | 3 + apps/web/src/composer-logic.test.ts | 6 +- apps/web/src/composer-logic.ts | 5 +- apps/web/src/composerDraftStore.ts | 26 + apps/web/src/editorPreferences.ts | 6 +- apps/web/src/hooks/useHandleNewThread.ts | 13 +- apps/web/src/hooks/useThreadActions.test.ts | 84 +- apps/web/src/hooks/useThreadActions.ts | 229 +++- apps/web/src/keybindings.test.ts | 10 + apps/web/src/lib/chatThreadActions.test.ts | 66 ++ apps/web/src/lib/chatThreadActions.ts | 1 + apps/web/src/localApi.test.ts | 30 + apps/web/src/localApi.ts | 12 + apps/web/src/openWith.test.ts | 98 ++ apps/web/src/openWith.ts | 101 ++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.board.tsx | 7 + apps/web/src/state/sourceControlActions.ts | 4 +- apps/web/vercel.ts | 2 +- packages/client-runtime/package.json | 4 + packages/client-runtime/src/rpc/client.ts | 14 +- .../src/state/gitActions.test.ts | 35 + .../client-runtime/src/state/gitActions.ts | 11 +- .../src/state/threads-sync.test.ts | 99 ++ packages/client-runtime/src/state/threads.ts | 54 +- packages/client-runtime/src/state/vcs.ts | 18 +- .../src/state/vcsAction.test.ts | 53 + .../client-runtime/src/state/vcsAction.ts | 43 +- .../src/state/vcsCommandScheduler.ts | 8 + .../src/state/worktreeCleanup.test.ts | 206 ++++ .../src/state/worktreeCleanup.ts | 91 ++ packages/contracts/src/git.test.ts | 100 ++ packages/contracts/src/git.ts | 63 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 11 + packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/openWith.test.ts | 77 ++ packages/contracts/src/openWith.ts | 179 +++ packages/contracts/src/orchestration.ts | 18 + packages/contracts/src/rpc.ts | 23 + packages/contracts/src/settings.test.ts | 30 + packages/contracts/src/settings.ts | 13 + packages/shared/src/composerTrigger.test.ts | 13 +- packages/shared/src/composerTrigger.ts | 5 +- packages/shared/src/keybindings.ts | 1 + 171 files changed, 14404 insertions(+), 990 deletions(-) create mode 100644 apps/desktop/src/electron/MacApplicationIcon.test.ts create mode 100644 apps/desktop/src/electron/MacApplicationIcon.ts create mode 100644 apps/desktop/src/ipc/methods/openWith.test.ts create mode 100644 apps/desktop/src/ipc/methods/openWith.ts create mode 100644 apps/desktop/src/shell/DesktopOpenWith.test.ts create mode 100644 apps/desktop/src/shell/DesktopOpenWith.ts create mode 100644 apps/mobile/src/features/home/threadActionMessages.ts create mode 100644 apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts create mode 100644 apps/mobile/src/features/home/worktreeCleanupPrompt.ts create mode 100644 apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts create mode 100644 apps/server/src/orchestration/Layers/WorktreeLifecycle.ts create mode 100644 apps/server/src/orchestration/Services/WorktreeLifecycle.ts create mode 100644 apps/server/src/provider/DirenvEnvironment.test.ts create mode 100644 apps/server/src/provider/DirenvEnvironment.ts create mode 100644 apps/server/src/provider/ProviderRestartRecovery.test.ts create mode 100644 apps/server/src/provider/ProviderRestartRecovery.ts create mode 100644 apps/server/src/provider/opencodeRuntime.test.ts create mode 100644 apps/web/src/components/board/Board.logic.test.ts create mode 100644 apps/web/src/components/board/Board.logic.ts create mode 100644 apps/web/src/components/board/BoardCard.test.tsx create mode 100644 apps/web/src/components/board/BoardCard.tsx create mode 100644 apps/web/src/components/board/BoardColumn.tsx create mode 100644 apps/web/src/components/board/BoardDragClickGuard.test.ts create mode 100644 apps/web/src/components/board/BoardDragClickGuard.ts create mode 100644 apps/web/src/components/board/BoardDropZones.tsx create mode 100644 apps/web/src/components/board/BoardScroll.test.ts create mode 100644 apps/web/src/components/board/BoardScroll.ts create mode 100644 apps/web/src/components/board/BoardView.tsx create mode 100644 apps/web/src/components/board/BoardWorktreeGroup.test.tsx create mode 100644 apps/web/src/components/board/BoardWorktreeGroup.tsx create mode 100644 apps/web/src/components/board/useBoardVcsStatuses.ts create mode 100644 apps/web/src/openWith.test.ts create mode 100644 apps/web/src/openWith.ts create mode 100644 apps/web/src/routes/_chat.board.tsx create mode 100644 packages/client-runtime/src/state/gitActions.test.ts create mode 100644 packages/client-runtime/src/state/worktreeCleanup.test.ts create mode 100644 packages/client-runtime/src/state/worktreeCleanup.ts create mode 100644 packages/contracts/src/openWith.test.ts create mode 100644 packages/contracts/src/openWith.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21fbce026f5..cd2823699a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -42,7 +42,7 @@ jobs: test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -63,7 +63,7 @@ jobs: mobile_native_static_analysis: name: Mobile Native Static Analysis - runs-on: blacksmith-12vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout @@ -84,7 +84,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 94d4af17e41..b1deffbf2d8 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -17,7 +17,7 @@ concurrency: jobs: deploy_relay: name: Deploy production relay - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: name: production diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 32e45fef54e..11eb0c64589 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -8,7 +8,7 @@ jobs: preview: name: EAS Preview if: contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 685df85e57c..f85782596e3 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -33,7 +33,7 @@ on: jobs: production: name: EAS Production ${{ inputs.mode }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read env: diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 36dfb61f73f..dfc3db4484c 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -32,7 +32,7 @@ jobs: ios: name: iPhone 6.9, iPhone 6.5, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 60 steps: - name: Checkout @@ -70,7 +70,7 @@ jobs: android: name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 60 env: T3_SHOWCASE_ANDROID_ABI: x86_64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 668d1fcb59d..57940256db8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: check_changes: name: Check for changes since last nightly if: github.event_name == 'schedule' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 outputs: has_changes: ${{ steps.check.outputs.has_changes }} steps: @@ -66,7 +66,7 @@ jobs: if: | !failure() && !cancelled() && (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: release_channel: ${{ steps.release_meta.outputs.release_channel }} @@ -170,7 +170,7 @@ jobs: name: Resolve T3 Connect public config needs: preflight if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 environment: name: production @@ -262,7 +262,7 @@ jobs: name: Build WSL node-pty (linux-x64) needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout @@ -322,22 +322,22 @@ jobs: matrix: include: - label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: arm64 - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: x64 - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 + runner: ubuntu-24.04 platform: linux target: AppImage arch: x64 - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 + runner: windows-2025 platform: win target: nsis arch: x64 @@ -609,7 +609,7 @@ jobs: name: Publish CLI to npm needs: [preflight, relay_public_config, build] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 # ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -666,7 +666,7 @@ jobs: name: Publish GitHub Release needs: [preflight, build, publish_cli] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - id: app_token @@ -783,7 +783,7 @@ jobs: name: Deploy hosted web app needs: [preflight, relay_public_config, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} @@ -897,7 +897,7 @@ jobs: name: Finalize release if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - id: app_token @@ -978,7 +978,7 @@ jobs: needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') needs: [preflight, relay_public_config, release, deploy_web, finalize] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7..9aaac689c34 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; @@ -66,7 +67,13 @@ function makePoolLayer( resolveWsl: () => Effect.die("unexpected WSL config resolve"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), - ElectronDialog.layer, + ElectronDialog.layer.pipe( + Layer.provide( + Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: () => Effect.die("unexpected application icon resolution"), + } satisfies MacApplicationIcon.MacApplicationIcon["Service"]), + ), + ), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window create"), ensureMain: Effect.die("unexpected window ensure"), diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 388b3fd2c15..d54d82e1ca2 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -1,17 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type { BrowserWindow } from "electron"; import { beforeEach, vi } from "vite-plus/test"; import * as ElectronDialog from "./ElectronDialog.ts"; +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; -const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), - showOpenDialogMock: vi.fn(), - showErrorBoxMock: vi.fn(), -})); +const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock, resolveDataUrlMock } = vi.hoisted( + () => ({ + showMessageBoxMock: vi.fn(), + showOpenDialogMock: vi.fn(), + showErrorBoxMock: vi.fn(), + resolveDataUrlMock: vi.fn(), + }), +); vi.mock("electron", () => ({ dialog: { @@ -21,13 +26,79 @@ vi.mock("electron", () => ({ }, })); +const applicationIconLayer = Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: (applicationPath) => + Effect.tryPromise({ + try: () => resolveDataUrlMock(applicationPath), + catch: (cause) => + new MacApplicationIcon.MacApplicationIconResolutionError({ applicationPath, cause }), + }), +} satisfies MacApplicationIcon.MacApplicationIcon["Service"]); +const dialogLayer = ElectronDialog.layer.pipe(Layer.provide(applicationIconLayer)); + describe("ElectronDialog", () => { beforeEach(() => { showMessageBoxMock.mockReset(); showOpenDialogMock.mockReset(); showErrorBoxMock.mockReset(); + resolveDataUrlMock.mockReset(); }); + it.effect("selects a macOS application and resolves its system icon", () => + Effect.gen(function* () { + const owner = { id: 12 } as BrowserWindow; + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/Applications/Terminal.app"], + }); + resolveDataUrlMock.mockResolvedValue("data:image/png;base64,icon"); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.pickApplication({ owner: Option.some(owner) }); + + assert.deepEqual(Option.getOrNull(result), { + applicationPath: "/Applications/Terminal.app", + suggestedName: "Terminal", + iconDataUrl: "data:image/png;base64,icon", + }); + assert.deepEqual(showOpenDialogMock.mock.calls[0], [ + owner, + { + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }, + ]); + assert.deepEqual(resolveDataUrlMock.mock.calls[0], ["/Applications/Terminal.app"]); + }).pipe(Effect.provide(dialogLayer)), + ); + + it.effect("returns none when application selection is cancelled", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }); + const dialog = yield* ElectronDialog.ElectronDialog; + assert.isTrue(Option.isNone(yield* dialog.pickApplication({ owner: Option.none() }))); + assert.equal(resolveDataUrlMock.mock.calls.length, 0); + }).pipe(Effect.provide(dialogLayer)), + ); + + it.effect("keeps a valid selection when icon extraction fails", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/Users/test/Applications/My Tool.app"], + }); + resolveDataUrlMock.mockRejectedValue(new Error("icon unavailable")); + const dialog = yield* ElectronDialog.ElectronDialog; + + assert.deepEqual(Option.getOrNull(yield* dialog.pickApplication({ owner: Option.none() })), { + applicationPath: "/Users/test/Applications/My Tool.app", + suggestedName: "My Tool", + iconDataUrl: null, + }); + }).pipe(Effect.provide(dialogLayer)), + ); + it.effect("returns false without opening a confirm dialog for empty messages", () => Effect.gen(function* () { const dialog = yield* ElectronDialog.ElectronDialog; @@ -39,7 +110,7 @@ describe("ElectronDialog", () => { assert.isFalse(result); assert.equal(showMessageBoxMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("opens a confirm dialog for the owner window", () => @@ -65,7 +136,7 @@ describe("ElectronDialog", () => { message: "Delete worktree?", }, ]); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("opens an app-level confirm dialog when there is no owner window", () => @@ -89,7 +160,7 @@ describe("ElectronDialog", () => { message: "Delete worktree?", }, ]); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves folder picker request context and cause", () => @@ -114,7 +185,7 @@ describe("ElectronDialog", () => { assert.include(error.message, "window 7"); assert.include(error.message, "/workspace"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves confirmation request context and cause", () => @@ -139,7 +210,7 @@ describe("ElectronDialog", () => { assert.include(error.message, "window 9"); assert.notInclude(error.message, "Confirm removal?"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves message box request context and cause", () => @@ -176,7 +247,7 @@ describe("ElectronDialog", () => { assert.notInclude(error.message, "Cancel"); assert.notInclude(error.message, "Discard"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves error box request context and cause in the defect", () => @@ -201,6 +272,6 @@ describe("ElectronDialog", () => { assert.notInclude(error.message, "Startup failed"); assert.notInclude(error.message, "Could not start."); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea..83d53d9dab9 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -1,10 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off - Electron's native picker returns host paths. 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 NodePath from "node:path"; import * as Electron from "electron"; +import type { DesktopApplicationSelection } from "@t3tools/contracts"; + +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; const CONFIRM_BUTTON_INDEX = 1; @@ -23,6 +28,19 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickApplicationError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + selectedPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to select a macOS application."; + } +} + export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( "ElectronDialogConfirmError", { @@ -69,6 +87,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; } +export interface ElectronDialogPickApplicationInput { + readonly owner: Option.Option; +} + export interface ElectronDialogConfirmInput { readonly owner: Option.Option; readonly message: string; @@ -92,6 +115,12 @@ export class ElectronDialog extends Context.Service< readonly pickFolder: ( input: ElectronDialogPickFolderInput, ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly pickApplication: ( + input: ElectronDialogPickApplicationInput, + ) => Effect.Effect< + Option.Option, + ElectronDialogPickApplicationError + >; readonly confirm: ( input: ElectronDialogConfirmInput, ) => Effect.Effect; @@ -102,97 +131,148 @@ export class ElectronDialog extends Context.Service< } >()("@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"], - }), - onSome: (defaultPath) => ({ - properties: ["openDirectory", "createDirectory"], - defaultPath, - }), - }); - const result = yield* Effect.tryPromise({ - try: () => - Option.match(input.owner, { - onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), - onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), +export const make = Effect.gen(function* () { + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + return 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"], }), - catch: (cause) => - new ElectronDialogPickFolderError({ - ownerWindowId, + onSome: (defaultPath) => ({ + properties: ["openDirectory", "createDirectory"], defaultPath, - cause, - }), - }); - - if (result.canceled) { - return Option.none(); - } - return Option.fromNullishOr(result.filePaths[0]); - }), - confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { - const normalizedMessage = input.message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - 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({ + }); + 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) { + return Option.none(); + } + return Option.fromNullishOr(result.filePaths[0]); + }), + pickApplication: Effect.fn("desktop.electron.dialog.pickApplication")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const options: Electron.OpenDialogOptions = { + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(options), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, options), + }), + catch: (cause) => + new ElectronDialogPickApplicationError({ + ownerWindowId, + selectedPath: null, + cause, + }), + }); + if (result.canceled) return Option.none(); + + const applicationPath = result.filePaths[0]; + if ( + applicationPath === undefined || + !NodePath.isAbsolute(applicationPath) || + !applicationPath.toLowerCase().endsWith(".app") + ) { + return yield* new ElectronDialogPickApplicationError({ ownerWindowId, - promptLength: normalizedMessage.length, - cause, - }), - }); - return result.response === CONFIRM_BUTTON_INDEX; - }), - 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, - }), + selectedPath: applicationPath ?? null, + cause: new Error("The selected path is not an absolute .app bundle."), + }); + } + + const iconDataUrl = yield* applicationIcon + .resolveDataUrl(applicationPath) + .pipe(Effect.orElseSucceed(() => null)); + return Option.some({ + applicationPath, + suggestedName: NodePath.basename(applicationPath, NodePath.extname(applicationPath)), + iconDataUrl, + }); }), - 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), + confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { + const normalizedMessage = input.message.trim(); + if (normalizedMessage.length === 0) { + return false; + } + + const options = { + type: "question" as const, + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: normalizedMessage, + }; + 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.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); +export const layer = Layer.effect(ElectronDialog, make); diff --git a/apps/desktop/src/electron/MacApplicationIcon.test.ts b/apps/desktop/src/electron/MacApplicationIcon.test.ts new file mode 100644 index 00000000000..85f168b0e62 --- /dev/null +++ b/apps/desktop/src/electron/MacApplicationIcon.test.ts @@ -0,0 +1,60 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; + +const { createFromBufferMock, getFileIconMock } = vi.hoisted(() => ({ + createFromBufferMock: vi.fn(), + getFileIconMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { getFileIcon: getFileIconMock }, + nativeImage: { createFromBuffer: createFromBufferMock }, +})); + +const applicationIconLayer = MacApplicationIcon.layer.pipe(Layer.provide(NodeServices.layer)); + +const provideLayer = (effect: Effect.Effect) => + effect.pipe(Effect.provide(applicationIconLayer)); + +describe("MacApplicationIcon", () => { + beforeEach(() => { + createFromBufferMock.mockReset(); + getFileIconMock.mockReset(); + }); + + it.effect("falls back to Electron's system icon lookup", () => + provideLayer( + Effect.gen(function* () { + getFileIconMock.mockResolvedValue({ toDataURL: () => "data:image/png;base64,icon" }); + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + assert.equal( + yield* applicationIcon.resolveDataUrl("/missing/Fixture.app"), + "data:image/png;base64,icon", + ); + assert.deepEqual(getFileIconMock.mock.calls[0], [ + "/missing/Fixture.app", + { size: "large" }, + ]); + }), + ), + ); + + it.effect("reports system icon lookup failures with the application path", () => + provideLayer( + Effect.gen(function* () { + getFileIconMock.mockRejectedValue(new Error("icon unavailable")); + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + const error = yield* Effect.flip(applicationIcon.resolveDataUrl("/missing/Fixture.app")); + assert.equal(error._tag, "MacApplicationIconResolutionError"); + assert.equal(error.applicationPath, "/missing/Fixture.app"); + }), + ), + ); +}); diff --git a/apps/desktop/src/electron/MacApplicationIcon.ts b/apps/desktop/src/electron/MacApplicationIcon.ts new file mode 100644 index 00000000000..48f096a2e71 --- /dev/null +++ b/apps/desktop/src/electron/MacApplicationIcon.ts @@ -0,0 +1,125 @@ +import * as Electron from "electron"; +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 * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +export class MacApplicationIconResolutionError extends Schema.TaggedErrorClass()( + "MacApplicationIconResolutionError", + { + applicationPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve the macOS application icon for ${this.applicationPath}.`; + } +} + +export class MacApplicationIcon extends Context.Service< + MacApplicationIcon, + { + readonly resolveDataUrl: ( + applicationPath: string, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/MacApplicationIcon") {} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const pathService = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const resolveBundleIconPath = Effect.fn( + "desktop.electron.macApplicationIcon.resolveBundleIconPath", + )(function* (applicationPath: string) { + const infoPlistPath = pathService.join(applicationPath, "Contents", "Info.plist"); + const configuredName = yield* spawner + .string( + ChildProcess.make( + "/usr/bin/plutil", + ["-extract", "CFBundleIconFile", "raw", infoPlistPath], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ), + ) + .pipe(Effect.map((output) => output.trim())); + if ( + configuredName.length === 0 || + pathService.basename(configuredName) !== configuredName || + configuredName === "." || + configuredName === ".." + ) { + return Option.none(); + } + const iconName = pathService.extname(configuredName) + ? configuredName + : `${configuredName}.icns`; + const iconPath = pathService.join(applicationPath, "Contents", "Resources", iconName); + const stat = yield* fs.stat(iconPath); + return stat.type === "File" ? Option.some(iconPath) : Option.none(); + }); + + const convertIcnsToDataUrl = Effect.fn( + "desktop.electron.macApplicationIcon.convertIcnsToDataUrl", + )(function* (applicationPath: string, iconPath: string) { + return yield* Effect.gen(function* () { + const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-open-with-icon-", + }); + const pngPath = pathService.join(temporaryDirectory, "icon.png"); + yield* spawner.string( + ChildProcess.make("/usr/bin/sips", ["-s", "format", "png", iconPath, "--out", pngPath], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ); + const png = yield* fs.readFile(pngPath); + const image = yield* Effect.try({ + try: () => Electron.nativeImage.createFromBuffer(Buffer.from(png)), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + if (image.isEmpty()) { + return yield* new MacApplicationIconResolutionError({ + applicationPath, + cause: new Error("The converted application icon is empty."), + }); + } + return image.toDataURL(); + }).pipe(Effect.scoped); + }); + + const resolveDataUrl = Effect.fn("desktop.electron.macApplicationIcon.resolveDataUrl")(function* ( + applicationPath: string, + ) { + // Electron can return a generic bundle icon, so prefer the app's declared ICNS asset. + const iconPath = yield* resolveBundleIconPath(applicationPath).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(iconPath)) { + const bundleIcon = yield* convertIcnsToDataUrl(applicationPath, iconPath.value).pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(bundleIcon)) return bundleIcon.value; + } + + const image = yield* Effect.tryPromise({ + try: () => Electron.app.getFileIcon(applicationPath, { size: "large" }), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + return yield* Effect.try({ + try: () => image.toDataURL(), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + }); + + return MacApplicationIcon.of({ resolveDataUrl }); +}); + +export const layer = Layer.effect(MacApplicationIcon, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..402e9d75a23 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -42,6 +42,11 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import { + openWith, + pickOpenWithApplication, + resolveOpenWithPresentations, +} from "./methods/openWith.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -83,6 +88,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(pickOpenWithApplication); + yield* ipc.handle(resolveOpenWithPresentations); + yield* ipc.handle(openWith); 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 95c725130e5..459fd1982ce 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,9 @@ 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 PICK_OPEN_WITH_APPLICATION_CHANNEL = "desktop:pick-open-with-application"; +export const RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL = "desktop:resolve-open-with-presentations"; +export const OPEN_WITH_CHANNEL = "desktop:open-with"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/openWith.test.ts b/apps/desktop/src/ipc/methods/openWith.test.ts new file mode 100644 index 00000000000..9e606199ebd --- /dev/null +++ b/apps/desktop/src/ipc/methods/openWith.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; +import { OpenWithEntryId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as DesktopOpenWith from "../../shell/DesktopOpenWith.ts"; +import { openWith, resolveOpenWithPresentations } from "./openWith.ts"; + +describe("Open With IPC methods", () => { + it.effect("encodes presentations and delegates launch inputs", () => + Effect.gen(function* () { + const opened = yield* Ref.make(null); + const entryId = OpenWithEntryId.make("terminal"); + const layer = Layer.succeed( + DesktopOpenWith.DesktopOpenWith, + DesktopOpenWith.DesktopOpenWith.of({ + resolvePresentations: Effect.succeed([ + { entryId, available: true, iconDataUrl: "data:image/png;base64,abc" }, + ]), + open: (input) => Ref.set(opened, input), + }), + ); + + assert.deepEqual( + yield* resolveOpenWithPresentations.handler(undefined).pipe(Effect.provide(layer)), + [{ entryId: "terminal", available: true, iconDataUrl: "data:image/png;base64,abc" }], + ); + yield* openWith + .handler({ + environmentId: "primary", + entryId: "terminal", + directory: "/tmp/project", + }) + .pipe(Effect.provide(layer)); + assert.deepEqual(yield* Ref.get(opened), { + environmentId: "primary", + entryId: "terminal", + directory: "/tmp/project", + }); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/openWith.ts b/apps/desktop/src/ipc/methods/openWith.ts new file mode 100644 index 00000000000..eb19e0ec7e3 --- /dev/null +++ b/apps/desktop/src/ipc/methods/openWith.ts @@ -0,0 +1,47 @@ +import { + DesktopApplicationSelection, + DesktopOpenWithInput, + OpenWithEntryPresentation, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopOpenWith from "../../shell/DesktopOpenWith.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const pickOpenWithApplication = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_OPEN_WITH_APPLICATION_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(DesktopApplicationSelection), + handler: Effect.fn("desktop.ipc.openWith.pickApplication")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + return Option.getOrNull( + yield* dialog.pickApplication({ owner: yield* electronWindow.focusedMainOrFirst }), + ); + }), +}); + +export const resolveOpenWithPresentations = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(OpenWithEntryPresentation), + handler: Effect.fn("desktop.ipc.openWith.resolvePresentations")(function* () { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + return yield* openWith.resolvePresentations; + }), +}); + +export const openWith = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_WITH_CHANNEL, + payload: DesktopOpenWithInput, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.openWith.open")(function* (input) { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + yield* openWith.open(input); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae..26e5d3ac412 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -23,6 +23,7 @@ import serverPackageJson from "../../server/package.json" with { type: "json" }; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; +import * as MacApplicationIcon from "./electron/MacApplicationIcon.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; @@ -49,6 +50,7 @@ import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopOpenWith from "./shell/DesktopOpenWith.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; @@ -120,7 +122,7 @@ const electronLayer = Layer.mergeAll( ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), -); +).pipe(Layer.provideMerge(MacApplicationIcon.layer)); const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, @@ -178,6 +180,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, DesktopShellEnvironment.layer, + DesktopOpenWith.layer, desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), @@ -195,10 +198,10 @@ 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), Layer.provideMerge(electronLayer), + Layer.provideMerge(NodeServices.layer), ), ), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 228114fd1d1..3a12ae1f39e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -105,6 +105,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + pickOpenWithApplication: () => ipcRenderer.invoke(IpcChannels.PICK_OPEN_WITH_APPLICATION_CHANNEL), + resolveOpenWithPresentations: () => + ipcRenderer.invoke(IpcChannels.RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL), + openWith: (input) => ipcRenderer.invoke(IpcChannels.OPEN_WITH_CHANNEL, input), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 6c942d4ccb9..fd5b75b83c6 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -334,6 +334,8 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ { key: "j", meta: true, shift: true, control: false }, // mod+K → command palette { key: "k", meta: true, shift: false, control: false }, + // mod+T → board + { key: "t", meta: true, shift: false, control: false }, // mod+, → settings (macOS convention) { key: ",", meta: true, shift: false, control: false }, // mod+W → close tab/panel diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..27c1bda3bc8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { ClientSettingsSchema, OpenWithEntryId, type ClientSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -16,10 +16,22 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + confirmWorktreeRemoval: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], - glassOpacity: 80, + providerFavorites: [], + openWithEntries: [ + { + id: OpenWithEntryId.make("terminal"), + name: "Terminal", + kind: "terminal", + invocation: { type: "mac-application", applicationPath: "/Applications/Terminal.app" }, + directoryMode: "open-target", + arguments: [], + }, + ], + preferredOpenWith: { type: "custom", id: OpenWithEntryId.make("terminal") }, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/desktop/src/shell/DesktopOpenWith.test.ts b/apps/desktop/src/shell/DesktopOpenWith.test.ts new file mode 100644 index 00000000000..e9074848688 --- /dev/null +++ b/apps/desktop/src/shell/DesktopOpenWith.test.ts @@ -0,0 +1,225 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + OpenWithEntry, + OpenWithEntryId, + PRIMARY_LOCAL_ENVIRONMENT_ID, +} from "@t3tools/contracts"; +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 Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as DesktopOpenWith from "./DesktopOpenWith.ts"; + +const decodeEntry = Schema.decodeUnknownSync(OpenWithEntry); + +const configuredEntries = [ + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "open-target", + arguments: [], + }), + decodeEntry({ + id: "missing", + name: "Missing", + kind: "other", + invocation: { type: "command", executable: "/definitely/missing/t3-open-with" }, + directoryMode: "open-target", + arguments: [], + }), +] as const; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: "/tmp", + platform: "darwin", + processArch: "arm64", + appVersion: "1.0.0", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +}).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ T3CODE_HOME: "/tmp/t3-open-with" }), + ), + ), +); + +const openWithLayer = DesktopOpenWith.layer.pipe( + Layer.provideMerge( + Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: () => Effect.die("unexpected application icon resolution"), + } satisfies MacApplicationIcon.MacApplicationIcon["Service"]), + ), + Layer.provideMerge( + DesktopClientSettings.layerTest( + Option.some({ + ...DEFAULT_CLIENT_SETTINGS, + openWithEntries: configuredEntries, + }), + ), + ), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), +); + +describe("DesktopOpenWith launch resolution", () => { + it.effect("appends the directory for open-target command entries", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "open-target", + arguments: ["--flag"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch, { + command: "/bin/echo", + args: ["--flag", "/tmp/work tree"], + shell: false, + cwd: null, + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("sets cwd without adding a directory argument in working-directory mode", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "working-directory", + arguments: ["one argument"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch, { + command: "/bin/echo", + args: ["one argument"], + shell: false, + cwd: "/tmp/work tree", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("expands placeholders within independent argument rows without shell parsing", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "custom-arguments", + arguments: ["prefix={directory}=suffix", "literal value"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch.args, ["prefix=/tmp/work tree=suffix", "literal value"]); + assert.isFalse(launch.shell ?? false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("resolves CFBundleExecutable and reports missing bundle executables", () => + Effect.gen(function* () { + if (process.platform !== "darwin") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-with-test-" }); + const applicationPath = path.join(base, "Fixture.app"); + const contentsPath = path.join(applicationPath, "Contents"); + const executableDirectory = path.join(contentsPath, "MacOS"); + yield* fileSystem.makeDirectory(executableDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(contentsPath, "Info.plist"), + ` + CFBundleExecutableFixture`, + ); + const executablePath = path.join(executableDirectory, "Fixture"); + yield* fileSystem.writeFileString(executablePath, "#!/bin/sh\n"); + + assert.equal( + yield* DesktopOpenWith.resolveMacBundleExecutable(applicationPath), + executablePath, + ); + yield* fileSystem.remove(executablePath); + const error = yield* Effect.flip(DesktopOpenWith.resolveMacBundleExecutable(applicationPath)); + assert.equal(error.reason, "missing-executable"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports available and missing command presentations", () => + Effect.gen(function* () { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + const presentations = yield* openWith.resolvePresentations; + assert.deepEqual(presentations, [ + { entryId: configuredEntries[0].id, available: true, iconDataUrl: null }, + { + entryId: configuredEntries[1].id, + available: false, + iconDataUrl: null, + unavailableReason: "Executable not found: /definitely/missing/t3-open-with", + }, + ]); + }).pipe(Effect.provide(openWithLayer)), + ); + + it.effect("rejects secondary environments, invalid targets, and unknown entry ids", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-target-" }); + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + + const remoteError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make("ssh:test"), + entryId: configuredEntries[0].id, + directory, + }), + ); + assert.equal(remoteError._tag, "OpenWithEnvironmentError"); + + const relativeError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID), + entryId: configuredEntries[0].id, + directory: "relative/path", + }), + ); + assert.equal(relativeError._tag, "OpenWithInvalidTargetError"); + + const unknownError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID), + entryId: OpenWithEntryId.make("unknown"), + directory, + }), + ); + assert.equal(unknownError._tag, "OpenWithMissingEntryError"); + }).pipe(Effect.provide(openWithLayer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/shell/DesktopOpenWith.ts b/apps/desktop/src/shell/DesktopOpenWith.ts new file mode 100644 index 00000000000..464d71fce48 --- /dev/null +++ b/apps/desktop/src/shell/DesktopOpenWith.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off - macOS bundle paths use the host path grammar. +import { + OpenWithBundleResolutionError, + OpenWithEnvironmentError, + OpenWithInvalidTargetError, + OpenWithMissingEntryError, + OpenWithSpawnError, + OpenWithUnavailableApplicationError, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type DesktopOpenWithInput, + type OpenWithEntry, + type OpenWithEntryPresentation, + type OpenWithLaunchError, +} from "@t3tools/contracts"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; +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 NodePath from "node:path"; +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 MacApplicationIcon from "../electron/MacApplicationIcon.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; + +interface ResolvedLaunch { + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string | null; + readonly shell?: boolean; +} + +const statPath = (path: string) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.stat(path)), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + +const applicationUnavailableReason = (entry: OpenWithEntry): string => + entry.invocation.type === "mac-application" + ? `Application not found: ${entry.invocation.applicationPath}` + : `Executable not found: ${entry.invocation.executable}`; + +const isMacApplicationPath = (value: string): boolean => + NodePath.isAbsolute(value) && value.toLowerCase().endsWith(".app"); + +const commandExists = Effect.fn("desktop.openWith.commandExists")(function* (command: string) { + return yield* resolveCommandPath(command).pipe( + Effect.as(true), + Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + ); +}); + +const entryIsAvailable = Effect.fn("desktop.openWith.entryIsAvailable")(function* ( + entry: OpenWithEntry, + platform: NodeJS.Platform, +) { + if (entry.invocation.type === "command") { + return yield* commandExists(entry.invocation.executable); + } + if (platform !== "darwin" || !isMacApplicationPath(entry.invocation.applicationPath)) { + return false; + } + const stat = yield* statPath(entry.invocation.applicationPath); + return Option.isSome(stat) && stat.value.type === "Directory"; +}); + +export const resolveMacBundleExecutable = Effect.fn("desktop.openWith.resolveMacBundleExecutable")( + function* (applicationPath: string) { + if (!isMacApplicationPath(applicationPath)) { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "invalid-application-path", + }); + } + const infoPlistPath = NodePath.join(applicationPath, "Contents", "Info.plist"); + const plistStat = yield* statPath(infoPlistPath); + if (Option.isNone(plistStat) || plistStat.value.type !== "File") { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "missing-info-plist", + }); + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const executableName = yield* spawner + .string( + ChildProcess.make( + "/usr/bin/plutil", + ["-extract", "CFBundleExecutable", "raw", "-o", "-", infoPlistPath], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ), + ) + .pipe( + Effect.map((output) => output.trim()), + Effect.mapError( + (cause) => + new OpenWithBundleResolutionError({ + applicationPath, + reason: "malformed-info-plist", + cause, + }), + ), + ); + if ( + executableName.length === 0 || + executableName.includes("/") || + executableName.includes("\\") + ) { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "malformed-info-plist", + }); + } + const executablePath = NodePath.join(applicationPath, "Contents", "MacOS", executableName); + const executableStat = yield* statPath(executablePath); + if (Option.isNone(executableStat) || executableStat.value.type !== "File") { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "missing-executable", + }); + } + return executablePath; + }, +); + +const expandDirectoryArguments = (args: readonly string[], directory: string): string[] => + args.map((argument) => argument.replaceAll("{directory}", directory)); + +export const resolveOpenWithLaunch = Effect.fn("desktop.openWith.resolveLaunch")(function* ( + entry: OpenWithEntry, + directory: string, + platform: NodeJS.Platform, +): Effect.fn.Return< + ResolvedLaunch, + OpenWithLaunchError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (!(yield* entryIsAvailable(entry, platform))) { + return yield* new OpenWithUnavailableApplicationError({ + entryId: entry.id, + executable: + entry.invocation.type === "mac-application" + ? entry.invocation.applicationPath + : entry.invocation.executable, + }); + } + + if (entry.directoryMode === "open-target") { + if (entry.invocation.type === "mac-application") { + return { + command: "/usr/bin/open", + args: ["-a", entry.invocation.applicationPath, directory], + cwd: null, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, [ + ...entry.arguments, + directory, + ]); + return { ...command, cwd: null }; + } + + if (entry.directoryMode === "working-directory") { + if (entry.invocation.type === "mac-application") { + return { + command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), + args: [...entry.arguments], + cwd: directory, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, entry.arguments); + return { ...command, cwd: directory }; + } + + if (!entry.arguments.some((argument) => argument.includes("{directory}"))) { + return yield* new OpenWithUnavailableApplicationError({ + entryId: entry.id, + executable: "Custom arguments must include {directory}", + }); + } + const args = expandDirectoryArguments(entry.arguments, directory); + if (entry.invocation.type === "mac-application") { + return { + command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), + args, + cwd: null, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, args); + return { ...command, cwd: null }; +}); + +const spawnDetached = ( + entry: OpenWithEntry, + launch: ResolvedLaunch & { readonly shell?: boolean }, +) => + ChildProcessSpawner.ChildProcessSpawner.pipe( + Effect.flatMap((spawner) => + spawner.spawn( + ChildProcess.make(launch.command, launch.args, { + ...(launch.cwd === null ? {} : { cwd: launch.cwd }), + detached: true, + shell: launch.shell ?? false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ), + ), + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + Effect.mapError( + (cause) => + new OpenWithSpawnError({ + entryId: entry.id, + command: launch.command, + args: [...launch.args], + cwd: launch.cwd, + cause, + }), + ), + ); + +export class DesktopOpenWith extends Context.Service< + DesktopOpenWith, + { + readonly resolvePresentations: Effect.Effect; + readonly open: (input: DesktopOpenWithInput) => Effect.Effect; + } +>()("@t3tools/desktop/shell/DesktopOpenWith") {} + +export const make = Effect.gen(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + const providePlatformServices = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ): Effect.Effect => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const resolvePresentations = Effect.gen(function* () { + const settings = yield* clientSettings.get; + if (Option.isNone(settings)) return []; + return yield* Effect.forEach(settings.value.openWithEntries, (entry) => + Effect.gen(function* () { + const available = yield* providePlatformServices( + entryIsAvailable(entry, environment.platform), + ); + const iconDataUrl = + available && entry.invocation.type === "mac-application" + ? yield* applicationIcon + .resolveDataUrl(entry.invocation.applicationPath) + .pipe(Effect.orElseSucceed(() => null)) + : null; + return { + entryId: entry.id, + available, + iconDataUrl, + ...(available ? {} : { unavailableReason: applicationUnavailableReason(entry) }), + } satisfies OpenWithEntryPresentation; + }), + ); + }).pipe(Effect.withSpan("desktop.openWith.resolvePresentations")); + + const open = Effect.fn("desktop.openWith.open")(function* (input: DesktopOpenWithInput) { + if (input.environmentId !== PRIMARY_LOCAL_ENVIRONMENT_ID) { + return yield* new OpenWithEnvironmentError({ environmentId: input.environmentId }); + } + if (!NodePath.isAbsolute(input.directory)) { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "relative", + }); + } + const targetStat = yield* providePlatformServices(statPath(input.directory)); + if (Option.isNone(targetStat)) { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "missing", + }); + } + if (targetStat.value.type !== "Directory") { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "not-directory", + }); + } + const settings = yield* clientSettings.get; + const entry = Option.isSome(settings) + ? settings.value.openWithEntries.find((candidate) => candidate.id === input.entryId) + : undefined; + if (entry === undefined) { + return yield* new OpenWithMissingEntryError({ entryId: input.entryId }); + } + const launch = yield* providePlatformServices( + resolveOpenWithLaunch(entry, input.directory, environment.platform), + ); + yield* providePlatformServices(spawnDetached(entry, launch)); + }); + + return DesktopOpenWith.of({ resolvePresentations, open }); +}); + +export const layer = Layer.effect(DesktopOpenWith, make); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..149caa79747 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -50,6 +50,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), + pickApplication: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, diff --git a/apps/mobile/src/features/home/threadActionMessages.ts b/apps/mobile/src/features/home/threadActionMessages.ts new file mode 100644 index 00000000000..680fc376c16 --- /dev/null +++ b/apps/mobile/src/features/home/threadActionMessages.ts @@ -0,0 +1,30 @@ +import * as Cause from "effect/Cause"; + +export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; + +const ACTION_VERBS: Record = { + archive: "archived", + unarchive: "unarchived", + delete: "deleted", + settle: "settled", + unsettle: "un-settled", +}; + +export 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; + } + return `The thread could not be ${ACTION_VERBS[action]}.`; +} + +export function actionFailureTitle(action: ThreadListAction): string { + if (action === "archive") return "Could not archive thread"; + if (action === "unarchive") return "Could not unarchive thread"; + if (action === "settle") return "Could not settle thread"; + if (action === "unsettle") return "Could not un-settle thread"; + return "Could not delete thread"; +} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index e200eb7acde..7f7c48ee7b3 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,6 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; +import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; +import { vcsEnvironment } from "../../state/vcs"; import { useAtomCommand } from "../../state/use-atom-command"; +import { + actionFailureMessage, + actionFailureTitle, + type ThreadListAction, +} from "./threadActionMessages"; +import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt"; /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ @@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en ); } -type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; - -const ACTION_VERBS: Record = { - archive: "archived", - unarchive: "unarchived", - delete: "deleted", - settle: "settled", - unsettle: "un-settled", -}; - -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; - } - return `The thread could not be ${ACTION_VERBS[action]}.`; -} - function selectionHaptic(): void { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } -function actionFailureTitle(action: ThreadListAction): string { - if (action === "archive") return "Could not archive thread"; - if (action === "unarchive") return "Could not unarchive thread"; - if (action === "settle") return "Could not settle thread"; - if (action === "unsettle") return "Could not un-settle thread"; - return "Could not delete thread"; -} - /** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, @@ -195,12 +177,89 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); + const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, { + reportFailure: false, + }); + const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, { + reportFailure: false, + }); const archiveThread = useCallback( (thread: EnvironmentThreadShell) => { - void executeAction("archive", thread); + void runArchiveWithWorktreeCleanup({ + // Server-authoritative preview; a failure (old server, transient + // error) degrades to a plain archive without a prompt. A thread + // mid-turn keeps the original archive guard: executeAction re-checks + // and surfaces the alert, so it must not be prompted for cleanup. + previewCandidate: async () => { + if (thread.session?.status === "running" && thread.session.activeTurnId != null) { + return null; + } + const preview = await previewWorktreeCleanup({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + return preview._tag === "Success" ? preview.value.candidate : null; + }, + removalPolicy: "confirm", + confirmRemoval: ({ displayWorktreePath }) => + presentWorktreeCleanupConfirmation({ + isIos: process.env.EXPO_OS === "ios", + displayWorktreePath, + presentAlert: (buttons) => { + Alert.alert(buttons.title, buttons.message, [ + { text: "Keep", style: "cancel", onPress: buttons.onKeep }, + { text: "Remove", style: "destructive", onPress: buttons.onRemove }, + ]); + }, + presentConfirmDialog: (buttons) => { + showConfirmDialog({ + title: buttons.title, + message: buttons.message, + cancelText: "Keep", + confirmText: "Remove", + destructive: true, + onConfirm: buttons.onRemove, + onCancel: buttons.onKeep, + }); + }, + }), + archive: () => executeAction("archive", thread), + isArchiveSuccess: (archived) => archived, + cleanup: async () => { + const result = await cleanupThreadWorktree({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + return { + kind: "failed", + message: + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The worktree could not be removed.", + } as const; + } + return { kind: "done", status: result.value.status } as const; + }, + // Cleanup problems are nonfatal: the archive itself already + // succeeded. + onCleanupFailed: (displayWorktreePath, message) => { + Alert.alert( + "Thread archived, but worktree removal failed", + `Could not remove ${displayWorktreePath}. ${message}`, + ); + }, + onCleanupRetained: (displayWorktreePath) => { + Alert.alert( + "Worktree kept", + `${displayWorktreePath} is still used by another active thread.`, + ); + }, + }); }, - [executeAction], + [cleanupThreadWorktree, executeAction, previewWorktreeCleanup], ); const settleThread = useCallback( async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, diff --git a/apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts b/apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts new file mode 100644 index 00000000000..502dd92b8f9 --- /dev/null +++ b/apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts @@ -0,0 +1,102 @@ +import { WorktreeLifecycleError } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages"; +import { + buildWorktreeCleanupPrompt, + presentWorktreeCleanupConfirmation, +} from "./worktreeCleanupPrompt"; + +describe("presentWorktreeCleanupConfirmation", () => { + it("uses the native alert on iOS and never the confirm dialog", async () => { + const presentAlert = vi.fn( + (buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => { + buttons.onRemove(); + }, + ); + const presentConfirmDialog = vi.fn(); + + const confirmation = await presentWorktreeCleanupConfirmation({ + isIos: true, + displayWorktreePath: "feature-1", + presentAlert, + presentConfirmDialog, + }); + + expect(confirmation).toEqual({ kind: "confirmed" }); + expect(presentAlert).toHaveBeenCalledTimes(1); + expect(presentConfirmDialog).not.toHaveBeenCalled(); + }); + + it("uses the confirm dialog host elsewhere", async () => { + const presentAlert = vi.fn(); + const presentConfirmDialog = vi.fn( + (buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => { + buttons.onKeep(); + }, + ); + + const confirmation = await presentWorktreeCleanupConfirmation({ + isIos: false, + displayWorktreePath: "feature-1", + presentAlert, + presentConfirmDialog, + }); + + expect(confirmation).toEqual({ kind: "declined" }); + expect(presentAlert).not.toHaveBeenCalled(); + expect(presentConfirmDialog).toHaveBeenCalledTimes(1); + }); + + it("resolves declined for Keep and confirmed for Remove", async () => { + const keep = presentWorktreeCleanupConfirmation({ + isIos: true, + displayWorktreePath: "feature-1", + presentAlert: (buttons) => { + buttons.onKeep(); + }, + presentConfirmDialog: () => {}, + }); + await expect(keep).resolves.toEqual({ kind: "declined" }); + + const remove = presentWorktreeCleanupConfirmation({ + isIos: false, + displayWorktreePath: "feature-1", + presentAlert: () => {}, + presentConfirmDialog: (buttons) => { + buttons.onRemove(); + }, + }); + await expect(remove).resolves.toEqual({ kind: "confirmed" }); + }); + + it("names the worktree in the prompt copy", () => { + const prompt = buildWorktreeCleanupPrompt("feature-1"); + expect(prompt.title).toBe("Remove worktree?"); + expect(prompt.message).toContain("feature-1"); + expect(prompt.message).toContain("branch is kept"); + }); +}); + +describe("thread action failure messages", () => { + it("surfaces unarchive restoration errors with the server-provided detail", () => { + const restorationError = new WorktreeLifecycleError({ + operation: "restore", + threadId: ThreadId.make("thread-1"), + detail: + "Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.", + }); + expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread"); + expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain( + "Failed to recreate the worktree", + ); + }); + + it("falls back to a generic message when the cause has no message", () => { + expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe( + "The thread could not be unarchived.", + ); + }); +}); diff --git a/apps/mobile/src/features/home/worktreeCleanupPrompt.ts b/apps/mobile/src/features/home/worktreeCleanupPrompt.ts new file mode 100644 index 00000000000..3c331e619ff --- /dev/null +++ b/apps/mobile/src/features/home/worktreeCleanupPrompt.ts @@ -0,0 +1,49 @@ +import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup"; + +export interface WorktreeCleanupPromptButtons { + readonly title: string; + readonly message: string; + readonly onKeep: () => void; + readonly onRemove: () => void; +} + +export function buildWorktreeCleanupPrompt(displayWorktreePath: string): { + readonly title: string; + readonly message: string; +} { + return { + title: "Remove worktree?", + message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`, + }; +} + +/** + * Presents the archive-time cleanup confirmation through the platform's + * surface: the native alert on iOS, the in-app confirm dialog elsewhere. + * Keep archives without cleanup; Remove archives and cleans up. + */ +export function presentWorktreeCleanupConfirmation(input: { + readonly isIos: boolean; + readonly displayWorktreePath: string; + readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void; + readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void; +}): Promise> { + const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath); + return new Promise((resolve) => { + const buttons: WorktreeCleanupPromptButtons = { + title, + message, + onKeep: () => { + resolve({ kind: "declined" }); + }, + onRemove: () => { + resolve({ kind: "confirmed" }); + }, + }; + if (input.isIos) { + input.presentAlert(buttons); + return; + } + input.presentConfirmDialog(buttons); + }); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 8e6819378a6..af37740c9d9 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -1,6 +1,7 @@ import type { StaticScreenProps } from "@react-navigation/native"; import { useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; +import type { ComposerDraftWorkspaceSelection } from "../../state/use-composer-drafts"; import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; @@ -10,8 +11,15 @@ type NewTaskDraftRouteParams = { readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; readonly incomingShareId?: string | string[]; + readonly workspaceMode?: string | string[]; + readonly branch?: string | string[]; + readonly worktreePath?: string | string[]; }; +function firstParam(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { const params = route.params ?? {}; @@ -27,6 +35,15 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps(() => { + const mode = firstParam(params.workspaceMode); + if (mode !== "local" && mode !== "worktree") return undefined; + return { + mode, + branch: firstParam(params.branch) ?? null, + worktreePath: firstParam(params.worktreePath) ?? null, + }; + }, [route.params]); return ( <> @@ -37,6 +54,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps ( + undefined, + ); + useEffect(() => { + const selection = props.initialWorkspaceSelection; + const initialEnvironmentId = props.initialProjectRef?.environmentId; + const initialProjectId = props.initialProjectRef?.projectId; + if ( + !selection || + !flow.draftKey || + !selectedProject || + selectedProject.environmentId !== initialEnvironmentId || + selectedProject.id !== initialProjectId || + appliedInitialWorkspaceSelectionRef.current === selection + ) { + return; + } + appliedInitialWorkspaceSelectionRef.current = selection; + updateComposerDraftSettings(flow.draftKey, { workspaceSelection: selection }); + }, [ + flow.draftKey, + props.initialProjectRef?.environmentId, + props.initialProjectRef?.projectId, + props.initialWorkspaceSelection, + selectedProject, + ]); + useEffect(() => { if (!selectedProject) { loadedBranchesProjectKeyRef.current = null; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 59af7199af7..48186c71ee6 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -10,6 +10,7 @@ import type { } from "@t3tools/contracts"; import { detectComposerTrigger, + parseStandaloneComposerSlashCommand, replaceTextRange, serializeComposerFileLink, type ComposerTrigger, @@ -110,6 +111,7 @@ export interface ThreadComposerProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + readonly onStartNewThread: () => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -372,6 +374,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (composerTrigger.kind === "slash-command") { const q = composerTrigger.query.toLowerCase(); const allBuiltIn = [ + { + id: "cmd:new", + type: "slash-command" as const, + command: "new", + label: "/new", + description: "Start a new thread here", + }, { id: "cmd:model", type: "slash-command" as const, @@ -512,9 +521,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── - const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; + const { + onChangeDraftMessage, + onStartNewThread, + onUpdateInteractionMode, + draftMessage, + onSendMessage, + } = props; const handleSend = useCallback(async () => { + if (parseStandaloneComposerSlashCommand(draftMessage) === "new") { + onChangeDraftMessage(""); + onStartNewThread(); + return; + } const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); @@ -531,6 +551,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } }, [ onSendMessage, + draftMessage, + onChangeDraftMessage, + onStartNewThread, props.environmentId, props.environmentLabel, props.selectedThread.id, @@ -556,6 +579,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return; } + if (item.type === "slash-command" && item.command === "new") { + const result = replaceTextRange( + draftMessage, + composerTrigger.rangeStart, + composerTrigger.rangeEnd, + "", + ); + setComposerSelection({ start: result.cursor, end: result.cursor }); + onChangeDraftMessage(result.text); + onStartNewThread(); + return; + } + let replacement = ""; if (item.type === "path") { replacement = `${serializeComposerFileLink(item.path)} `; @@ -576,7 +612,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); }, - [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], + [ + composerTrigger, + draftMessage, + onChangeDraftMessage, + onStartNewThread, + onUpdateInteractionMode, + ], ); // ── Model menu ─────────────────────────────────────────── diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5cb04290f66..8984e3d2ee7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -77,6 +77,7 @@ export interface ThreadDetailScreenProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + readonly onStartNewThread: () => void; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -439,6 +440,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onRemoveDraftImage={props.onRemoveDraftImage} onStopThread={props.onStopThread} onSendMessage={handleSendMessage} + onStartNewThread={props.onStartNewThread} onReconnectEnvironment={props.onReconnectEnvironment} onUpdateModelSelection={props.onUpdateThreadModelSelection} onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7fb4740ddce..7f6ec925380 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -308,6 +308,24 @@ function ThreadRouteContent( [knownTerminalSessions, selectedThreadProject?.workspaceRoot], ); const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; + const handleStartNewThread = useCallback(() => { + if (!selectedThread || !selectedThreadProject) return; + const worktreePath = resolvePreferredThreadWorktreePath({ + threadShellWorktreePath: selectedThread.worktreePath ?? null, + threadDetailWorktreePath: selectedThreadDetailWorktreePath, + }); + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(selectedThread.environmentId), + projectId: String(selectedThread.projectId), + title: selectedThreadProject.title, + workspaceMode: "local", + branch: selectedThread.branch ?? undefined, + worktreePath: worktreePath ?? undefined, + }, + }); + }, [navigation, selectedThread, selectedThreadDetailWorktreePath, selectedThreadProject]); const handleReconnectEnvironment = useCallback(() => { if (!environmentId) { return; @@ -781,6 +799,7 @@ function ThreadRouteContent( serverConfig={serverConfig} onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} + onStartNewThread={handleStartNewThread} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} 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 f320e9da710..9d9c7e2eb2b 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -3,6 +3,8 @@ 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 { + buildUnsignedCommitRetryInput, + isCommitSigningFailure, type GitActionRequestInput, type VcsActionOperation, type VcsRef, @@ -14,6 +16,7 @@ import { } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; +import { Alert } from "react-native"; import { useBranches } from "../state/queries"; import { threadEnvironment } from "../state/threads"; @@ -131,7 +134,10 @@ export function useSelectedThreadGitActions() { readonly project: EnvironmentProject; readonly cwd: string; }) => Promise>, - options?: { readonly managedExternally?: boolean }, + options?: { + readonly managedExternally?: boolean; + readonly onFailure?: (error: unknown) => boolean; + }, ): Promise => { if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { return null; @@ -154,6 +160,9 @@ export function useSelectedThreadGitActions() { : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); + if (options?.onFailure?.(error) === true) { + return null; + } const message = error instanceof Error ? error.message : "Git action failed."; setPendingConnectionError(message); showGitActionResult({ type: "error", title: "Git action failed", description: message }); @@ -319,49 +328,95 @@ export function useSelectedThreadGitActions() { const onRunSelectedThreadGitAction = useCallback( async (input: GitActionRequestInput): Promise => { - 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] } : {}), - }); - if (AsyncResult.isFailure(result)) { - return result; - } - - 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, - }); + async function runAction( + actionInput: GitActionRequestInput, + options?: { readonly syncCurrentBranchOnSuccess?: boolean }, + ): Promise { + const actionId = uuidv4(); + return await runSelectedThreadGitMutation( + "run_change_request", + "Running source control action", + async ({ thread, cwd }) => { + const result = await runStackedAction({ + actionId, + action: actionInput.action, + ...(actionInput.commitMessage ? { commitMessage: actionInput.commitMessage } : {}), + ...(actionInput.featureBranch ? { featureBranch: actionInput.featureBranch } : {}), + ...(actionInput.disableCommitSigning ? { disableCommitSigning: true } : {}), + ...(actionInput.filePaths?.length ? { filePaths: [...actionInput.filePaths] } : {}), + }); + if (AsyncResult.isFailure(result)) { + 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, - }, + 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, }); - if (AsyncResult.isFailure(syncResult)) { - return AsyncResult.failure(syncResult.cause); + + 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 if (options?.syncCurrentBranchOnSuccess) { + const status = await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + if (status?.refName) { + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: status.refName, + worktreePath: selectedThreadWorktreePath, + }, + }); + if (AsyncResult.isFailure(syncResult)) { + return AsyncResult.failure(syncResult.cause); + } + } + } else { + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); } - } else { - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - } - return result; - }, - { managedExternally: true }, - ); + return result; + }, + { + managedExternally: true, + onFailure: (error) => { + if (actionInput.disableCommitSigning || !isCommitSigningFailure(error)) { + return false; + } + Alert.alert( + "Commit signing failed", + "Git couldn’t sign the commit. Retry this commit without signing?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Retry without signing", + onPress: () => { + void runAction(buildUnsignedCommitRetryInput(actionInput), { + syncCurrentBranchOnSuccess: actionInput.featureBranch === true, + }); + }, + }, + ], + ); + return true; + }, + }, + ); + } + + return await runAction(input); }, [ runStackedAction, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ebc4f984b86..dc4a3f8df13 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -304,6 +304,7 @@ export const makeOrchestrationIntegrationHarness = ( ProjectionPendingApprovalRepositoryLive, checkpointStoreLayer, providerLayer, + providerSessionDirectoryLayer, RuntimeReceiptBusTest, ); const serverSettingsLayer = ServerSettingsService.layerTest(); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8e0e5fb74d5..42e3b7d1624 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -106,8 +106,10 @@ describe("CheckpointDiffQuery.layer", () => { }); }), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); @@ -199,8 +201,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); @@ -282,8 +286,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); @@ -350,8 +356,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); @@ -403,8 +411,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 256f10fb3b8..ed91dc32b9e 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -247,6 +247,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* runGit(cwd, ["config", "commit.gpgSign", "false"]); yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n"); yield* runGit(cwd, ["add", "README.md"]); yield* runGit(cwd, ["commit", "-m", "Initial commit"]); @@ -610,6 +611,7 @@ function runStackedAction( actionId?: string; commitMessage?: string; featureBranch?: boolean; + disableCommitSigning?: boolean; filePaths?: readonly string[]; }, options?: Parameters[1], @@ -623,6 +625,20 @@ function runStackedAction( ); } +const configureFailingCommitSigner = Effect.fn("configureFailingCommitSigner")(function* ( + repoDir: string, +) { + const signerPath = NodePath.join(repoDir, "failing-signer.sh"); + NodeFS.writeFileSync( + signerPath, + "#!/bin/sh\necho 'gpg: signing failed: No secret key' >&2\nexit 1\n", + { mode: 0o755 }, + ); + yield* runGit(repoDir, ["config", "commit.gpgSign", "true"]); + yield* runGit(repoDir, ["config", "gpg.format", "openpgp"]); + yield* runGit(repoDir, ["config", "gpg.program", signerPath]); +}); + function resolvePullRequest( manager: GitManager.GitManager["Service"], input: { cwd: string; reference: string }, @@ -3742,6 +3758,63 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("does not attribute ambiguous output to the wrong commit hook", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "multi-hook.txt"), "multi hook\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + '#!/bin/sh\necho "output from pre-commit" >&2\n', + { mode: 0o755 }, + ); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "commit-msg"), + '#!/bin/sh\necho "output from commit-msg" >&2\n', + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "test: preserve hook output attribution", + }, + { + actionId: "action-multi-hook", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("created"); + const hookOutput = events.filter((event) => event.kind === "hook_output"); + const preCommitOutput = hookOutput.find((event) => + event.text.includes("output from pre-commit"), + ); + const commitMsgOutput = hookOutput.find((event) => + event.text.includes("output from commit-msg"), + ); + const gitOutput = hookOutput.find((event) => + event.text.includes("test: preserve hook output attribution"), + ); + + expect(preCommitOutput).toBeDefined(); + expect([null, "pre-commit"]).toContain(preCommitOutput?.hookName); + expect(commitMsgOutput).toBeDefined(); + expect([null, "commit-msg"]).toContain(commitMsgOutput?.hookName); + expect(gitOutput).toMatchObject({ hookName: null }); + }), + ); + it.effect("emits action_failed when a commit hook rejects", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3791,12 +3864,155 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect.objectContaining({ kind: "action_failed", phase: "commit", + failureKind: "unknown", }), ]), ); }), ); + it.effect( + "classifies signing failures and retries a created feature branch without branching again", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "signing.txt"), "signing retry\n"); + yield* configureFailingCommitSigner(repoDir); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "feat: signing retry", + featureBranch: true, + }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe(Effect.flip); + + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "action_failed", + phase: "commit", + failureKind: "commit_signing_failed", + }), + ]), + ); + expect( + events.some( + (event) => event.kind === "hook_output" && event.text.includes("No secret key"), + ), + ).toBe(false); + const branchAfterFailure = yield* runGit(repoDir, ["branch", "--show-current"]).pipe( + Effect.map((result) => result.stdout.trim()), + ); + const branchCountAfterFailure = yield* runGit(repoDir, [ + "branch", + "--format=%(refname)", + ]).pipe(Effect.map((result) => result.stdout.trim().split("\n").length)); + + const retried = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + commitMessage: "feat: signing retry", + disableCommitSigning: true, + }); + const branchCountAfterRetry = yield* runGit(repoDir, [ + "branch", + "--format=%(refname)", + ]).pipe(Effect.map((result) => result.stdout.trim().split("\n").length)); + + expect(retried.commit.status).toBe("created"); + expect(retried.branch.status).toBe("skipped_not_requested"); + expect(branchAfterFailure).toBe("feature/feat-signing-retry"); + expect(branchCountAfterRetry).toBe(branchCountAfterFailure); + }), + ); + + for (const action of ["commit", "commit_push", "commit_push_pr"] as const) { + it.effect(`forwards the unsigned override for ${action}`, () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + if (action !== "commit") { + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", `feature/unsigned-${action}`]); + } + NodeFS.writeFileSync(NodePath.join(repoDir, `${action}.txt`), `${action}\n`); + yield* configureFailingCommitSigner(repoDir); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action, + commitMessage: `test: ${action} unsigned`, + disableCommitSigning: true, + }); + + expect(result.commit.status).toBe("created"); + if (action !== "commit") { + expect(result.push.status).toBe("pushed"); + } + if (action === "commit_push_pr") { + expect(result.pr.status).toBe("created"); + } + }), + ); + } + + it.effect("does not advertise another retry when an unsigned attempt fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "unsigned-hook.txt"), "hook failure\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + "#!/bin/sh\necho 'error: gpg failed to sign the data' >&2\nexit 1\n", + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "test: unsigned hook failure", + disableCommitSigning: true, + }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe(Effect.flip); + + const failure = events.find((event) => event.kind === "action_failed"); + expect(failure).toMatchObject({ + kind: "action_failed", + phase: "commit", + failureKind: "unknown", + }); + }), + ); + it.effect("create_pr emits only the PR phase when the branch is already pushed", () => 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 86e10d9f930..79612a9fea4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1377,6 +1377,7 @@ export const make = Effect.gen(function* () { commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], + disableSigning?: boolean, progressReporter?: GitActionProgressReporter, actionId?: string, ) { @@ -1419,28 +1420,51 @@ export const make = Effect.gen(function* () { }); let currentHookName: string | null = null; + let sawCommitHook = false; + let pendingUnattributedOutput: Array<{ stream: "stdout" | "stderr"; text: string }> = []; + const emitHookOutput = ( + hookName: string | null, + { stream, text }: { stream: "stdout" | "stderr"; text: string }, + ) => { + const sanitized = sanitizeProgressText(text); + if (!sanitized) { + return Effect.void; + } + return emit({ + kind: "hook_output", + hookName, + stream, + text: sanitized, + }); + }; + const finalizeUnattributedOutput = (shouldEmit: boolean) => + Effect.suspend(() => { + const pending = pendingUnattributedOutput; + pendingUnattributedOutput = []; + return shouldEmit + ? Effect.forEach(pending, (output) => emitHookOutput(null, output), { discard: true }) + : Effect.void; + }); const commitProgress = progressReporter && actionId ? { - onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { - const sanitized = sanitizeProgressText(text); - if (!sanitized) { - return Effect.void; - } - return emit({ - kind: "hook_output", - hookName: currentHookName, - stream, - text: sanitized, - }); - }, - onHookStarted: (hookName: string) => { - currentHookName = hookName; - return emit({ - kind: "hook_started", - hookName, - }); - }, + onOutputLine: (output: { stream: "stdout" | "stderr"; text: string }) => + Effect.suspend(() => { + if (currentHookName === null) { + pendingUnattributedOutput.push(output); + return Effect.void; + } + return emitHookOutput(currentHookName, output); + }), + onHookStarted: (hookName: string) => + Effect.suspend(() => { + sawCommitHook = true; + currentHookName = hookName; + return emit({ + kind: "hook_started", + hookName, + }); + }), onHookFinished: ({ hookName, exitCode, @@ -1462,10 +1486,19 @@ export const make = Effect.gen(function* () { }, } : null; - const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { - timeoutMs: COMMIT_TIMEOUT_MS, - ...(commitProgress ? { progress: commitProgress } : {}), - }); + const { commitSha } = yield* gitCore + .commit(cwd, suggestion.subject, suggestion.body, { + timeoutMs: COMMIT_TIMEOUT_MS, + ...(disableSigning ? { disableSigning: true } : {}), + ...(commitProgress ? { progress: commitProgress } : {}), + }) + .pipe( + Effect.tapError((error) => + finalizeUnattributedOutput( + sawCommitHook && error.failureKind !== "commit_signing_failed", + ), + ), + ); if (currentHookName !== null) { yield* emit({ kind: "hook_finished", @@ -1475,6 +1508,7 @@ export const make = Effect.gen(function* () { }); currentHookName = null; } + yield* finalizeUnattributedOutput(sawCommitHook); return { status: "created" as const, commitSha, @@ -1975,6 +2009,7 @@ export const make = Effect.gen(function* () { commitMessageForStep, preResolvedCommitSuggestion, input.filePaths, + input.disableCommitSigning, options?.progressReporter, progress.actionId, ), @@ -2041,6 +2076,10 @@ export const make = Effect.gen(function* () { kind: "action_failed", phase: Option.getOrNull(phase), message: error.message, + failureKind: + !input.disableCommitSigning && error._tag === "GitCommandError" + ? error.failureKind + : "unknown", }), ), ), diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadba..ca67f421908 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -170,6 +170,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: "Failed to resolve the VCS driver for this Git command.", cause, }), @@ -180,6 +181,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); } @@ -222,6 +224,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: "Failed to detect a VCS repository for this Git command.", cause, }), @@ -235,6 +238,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); } diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..af47ae338d5 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("board.open"), "mod+t"); 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"); diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 886833d6e2c..a09179cbab2 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -50,6 +50,10 @@ export const providerTurnsTotal = Metric.counter("t3_provider_turns_total", { description: "Total provider turn lifecycle operations.", }); +export const providerTurnRecoveriesTotal = Metric.counter("t3_provider_turn_recoveries_total", { + description: "Total provider turn restart-recovery candidates and outcomes.", +}); + export const providerTurnDuration = Metric.timer("t3_provider_turn_duration", { description: "Provider turn request duration.", }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 731002ea783..bee749e80bb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -201,8 +201,11 @@ describe("OrchestrationEngine", () => { getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..a05ada69b3d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -11,6 +11,7 @@ import { assert, 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 SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -570,6 +571,245 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("resolves session stop context for archived threads but not deleted ones", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_sessions`; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-archived-session', + 'project-stop-test', + 'Archived Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:00.000Z', + '2026-04-07T00:00:01.000Z', + '2026-04-07T00:00:02.000Z', + NULL + ), + ( + 'thread-deleted-session', + 'project-stop-test', + 'Deleted Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:00.000Z', + '2026-04-07T00:00:01.000Z', + NULL, + '2026-04-07T00:00:03.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_session_id, + provider_thread_id, + runtime_mode, + active_turn_id, + last_error, + updated_at + ) + VALUES ( + 'thread-archived-session', + 'running', + 'codex', + 'provider-session-stop', + 'provider-thread-stop', + 'full-access', + NULL, + NULL, + '2026-04-07T00:00:04.000Z' + ) + `; + + // The archived, nondeleted thread must resolve so the archive flow's + // session stop can still find it. + const archivedContext = yield* snapshotQuery.getSessionStopContextById( + ThreadId.make("thread-archived-session"), + ); + assert.isTrue(archivedContext._tag === "Some"); + if (archivedContext._tag === "Some") { + assert.equal(archivedContext.value.threadId, ThreadId.make("thread-archived-session")); + assert.equal(archivedContext.value.session?.status, "running"); + assert.equal(archivedContext.value.session?.providerName, "codex"); + } + + const deletedContext = yield* snapshotQuery.getSessionStopContextById( + ThreadId.make("thread-deleted-session"), + ); + assert.isTrue(deletedContext._tag === "None"); + + const archivedWithoutSession = yield* sql` + DELETE FROM projection_thread_sessions + `.pipe( + Effect.flatMap(() => + snapshotQuery.getSessionStopContextById(ThreadId.make("thread-archived-session")), + ), + ); + assert.isTrue( + archivedWithoutSession._tag === "Some" && archivedWithoutSession.value.session === null, + ); + }), + ); + + it.effect("reads thread lifecycle markers regardless of deleted/archived state", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_threads`; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-lifecycle-active', + 'project-lifecycle-test', + 'Active Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:03.000Z', + NULL, + NULL + ), + ( + 'thread-lifecycle-archived', + 'project-lifecycle-test', + 'Archived Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:04.000Z', + '2026-04-06T00:00:05.000Z', + '2026-04-06T00:00:06.000Z', + NULL + ), + ( + 'thread-lifecycle-deleted', + 'project-lifecycle-test', + 'Deleted Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:07.000Z', + '2026-04-06T00:00:08.000Z', + NULL, + '2026-04-06T00:00:09.000Z' + ) + `; + + const active = yield* snapshotQuery.getThreadLifecycleById( + ThreadId.make("thread-lifecycle-active"), + ); + assert.deepEqual(active, Option.some({ deletedAt: null, archivedAt: null })); + + const archived = yield* snapshotQuery.getThreadLifecycleById( + ThreadId.make("thread-lifecycle-archived"), + ); + assert.deepEqual( + archived, + Option.some({ deletedAt: null, archivedAt: "2026-04-06T00:00:06.000Z" }), + ); + + const deleted = yield* snapshotQuery.getThreadLifecycleById( + ThreadId.make("thread-lifecycle-deleted"), + ); + assert.deepEqual( + deleted, + Option.some({ deletedAt: "2026-04-06T00:00:09.000Z", archivedAt: null }), + ); + + const missing = yield* snapshotQuery.getThreadLifecycleById( + ThreadId.make("thread-lifecycle-missing"), + ); + assert.deepEqual(missing, Option.none()); + }), + ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..41e52e5bca9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -54,6 +54,7 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, type ProjectionFullThreadDiffContext, + type ProjectionSessionStopContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, @@ -748,6 +749,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getNondeletedThreadIdRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadIdLookupRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId" + FROM projection_threads + WHERE thread_id = ${threadId} + AND deleted_at IS NULL + LIMIT 1 + `, + }); + const getActiveThreadRowById = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadDbRowSchema, @@ -783,6 +798,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getThreadLifecycleRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: Schema.Struct({ + deletedAt: Schema.NullOr(IsoDateTime), + archivedAt: Schema.NullOr(IsoDateTime), + }), + execute: ({ threadId }) => + sql` + SELECT + deleted_at AS "deletedAt", + archived_at AS "archivedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + const listThreadMessageRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadMessageDbRowSchema, @@ -1873,6 +1905,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); }); + const getSessionStopContextById: ProjectionSnapshotQueryShape["getSessionStopContextById"] = ( + threadId, + ) => + Effect.gen(function* () { + const threadRow = yield* getNondeletedThreadIdRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSessionStopContextById:getThread:query", + "ProjectionSnapshotQuery.getSessionStopContextById:getThread:decodeRow", + ), + ), + ); + if (Option.isNone(threadRow)) { + return Option.none(); + } + + const sessionRow = yield* getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSessionStopContextById:getSession:query", + "ProjectionSnapshotQuery.getSessionStopContextById:getSession:decodeRow", + ), + ), + ); + + return Option.some({ + threadId: threadRow.value.threadId, + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + }); + }); + const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ @@ -2103,6 +2166,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const getThreadLifecycleById: ProjectionSnapshotQueryShape["getThreadLifecycleById"] = ( + threadId, + ) => + getThreadLifecycleRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadLifecycleById:query", + "ProjectionSnapshotQuery.getThreadLifecycleById:decodeRow", + ), + ), + ); + return { getCommandReadModel, getSnapshot, @@ -2115,8 +2190,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getFirstActiveThreadIdByProjectId, getThreadCheckpointContext, getFullThreadDiffContext, + getSessionStopContextById, getThreadShellById, getThreadDetailById, + getThreadLifecycleById, getThreadDetailSnapshot, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..3823a4a2cb4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -26,6 +26,7 @@ import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -37,11 +38,15 @@ import { TextGenerationError } from "@t3tools/contracts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; -import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { makeSqlitePersistenceLive } from "../../persistence/Layers/Sqlite.ts"; import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import type { ProviderRuntimeBindingWithMetadata } from "../../provider/Services/ProviderSessionDirectory.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; @@ -51,8 +56,10 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import { providerErrorLabel, providerErrorLabelFromInstanceHint, + RESTART_RECOVERY_CONTINUATION_INSTRUCTION, ProviderCommandReactorLive, } from "./ProviderCommandReactor.ts"; +import { makeProviderRestartRecoveryMarker } from "../../provider/ProviderRestartRecovery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -91,7 +98,10 @@ async function waitFor( describe("ProviderCommandReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | ProviderCommandReactor | ProjectionSnapshotQuery, + | OrchestrationEngineService + | ProviderCommandReactor + | ProjectionSnapshotQuery + | ProjectionTurnRepository, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -146,16 +156,21 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; - readonly startSessionEffect?: ( - session: ProviderSession, - ) => Effect.Effect; + readonly deferReactorStart?: boolean; + readonly providerBindings?: ReadonlyArray; + readonly providerBindingsMap?: Map; + readonly skipDefaultSetup?: boolean; + readonly providerInstanceEnabled?: boolean; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = input?.baseDir ?? NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-reactor-")); createdBaseDirs.add(baseDir); - const { stateDir } = deriveServerPathsSync(baseDir, undefined); + const { stateDir, dbPath } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); + const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe( + Layer.provide(NodeServices.layer), + ); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const runtimeSessions: Array = []; @@ -302,6 +317,30 @@ describe("ProviderCommandReactor", () => { : {}), }, ]; + const providerBindings = + input?.providerBindingsMap ?? + new Map((input?.providerBindings ?? []).map((binding) => [binding.threadId, binding])); + const directoryUpsert = vi.fn( + (binding: Parameters[0]) => + Effect.sync(() => { + const existing = providerBindings.get(binding.threadId); + providerBindings.set(binding.threadId, { + ...existing, + ...binding, + providerInstanceId: binding.providerInstanceId, + lastSeenAt: existing?.lastSeenAt ?? now, + runtimePayload: + existing?.runtimePayload && + typeof existing.runtimePayload === "object" && + !Array.isArray(existing.runtimePayload) && + binding.runtimePayload && + typeof binding.runtimePayload === "object" && + !Array.isArray(binding.runtimePayload) + ? { ...existing.runtimePayload, ...binding.runtimePayload } + : (binding.runtimePayload ?? existing?.runtimePayload ?? null), + } as ProviderRuntimeBindingWithMetadata); + }), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -325,7 +364,7 @@ describe("ProviderCommandReactor", () => { instanceId, driverKind, displayName: undefined, - enabled: true, + enabled: input?.providerInstanceEnabled ?? true, continuationIdentity: { driverKind, continuationKey: @@ -347,16 +386,26 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), - Layer.provide(SqlitePersistenceMemory), + Layer.provide(persistenceLayer), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(RepositoryIdentityResolver.layer), - Layer.provide(SqlitePersistenceMemory), + Layer.provide(persistenceLayer), ); const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + Layer.provideMerge( + Layer.succeed(ProviderSessionDirectory, { + upsert: directoryUpsert, + getProvider: () => Effect.die("getProvider should not be called in this test"), + getBinding: (threadId) => + Effect.succeed(Option.fromNullishOr(providerBindings.get(threadId))), + listThreadIds: () => Effect.succeed(Array.from(providerBindings.keys())), + listBindings: () => Effect.succeed(Array.from(providerBindings.values())), + }), + ), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ @@ -381,46 +430,61 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(persistenceLayer), + Layer.provideMerge(ProjectionTurnRepositoryLive.pipe(Layer.provide(persistenceLayer))), ); runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); + const turnRepository = await runtime.runPromise(Effect.service(ProjectionTurnRepository)); scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); + let reactorStarted = false; + const startReactor = async () => { + if (reactorStarted) return; + reactorStarted = true; + await Effect.runPromise(reactor.start().pipe(Scope.provide(scope!))); + }; + if (input?.deferReactorStart !== true) { + await startReactor(); + } const drain = () => Effect.runPromise(reactor.drain); - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.make("cmd-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot: "/tmp/provider-project", - defaultModelSelection: modelSelection, - createdAt: now, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.make("cmd-thread-create"), - threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: modelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt: now, - }), - ); + if (input?.skipDefaultSetup !== true) { + await Effect.runPromise( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot: "/tmp/provider-project", + defaultModelSelection: modelSelection, + createdAt: now, + }), + ); + await Effect.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + } return { engine, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), + readTurns: (threadId: ThreadId) => + Effect.runPromise(turnRepository.listByThreadId({ threadId })), startSession, sendTurn, interruptTurn, @@ -432,8 +496,11 @@ describe("ProviderCommandReactor", () => { generateBranchName, generateThreadTitle, runtimeSessions, + directoryUpsert, + providerBindings, stateDir, drain, + startReactor, }; } @@ -477,115 +544,584 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - effectIt.effect("projects starting before a slow provider session finishes", () => - Effect.gen(function* () { - const releaseStart = yield* Deferred.make(); - const harness = yield* Effect.promise(() => - createHarness({ - startSessionEffect: (session) => Deferred.await(releaseStart).pipe(Effect.as(session)), - }), - ); - const now = "2026-01-01T00:00:00.000Z"; - - yield* harness.engine.dispatch({ + it("replays a persisted pending turn start exactly once on startup", async () => { + const harness = await createHarness({ deferReactorStart: true }); + const now = "2026-01-01T00:00:00.000Z"; + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-plan-before-pending"), + threadId: ThreadId.make("thread-1"), + interactionMode: "plan", + createdAt: now, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-slow-provider"), + commandId: CommandId.make("cmd-pending-before-restart"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-slow-provider"), + messageId: asMessageId("pending-user-message"), role: "user", - text: "start slowly", + text: "resume this exact pending request", attachments: [], }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + interactionMode: "plan", runtimeMode: "approval-required", createdAt: now, - }); - - yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); - const duringStartup = yield* Effect.promise(() => harness.readModel()); - expect( - duringStartup.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session - ?.status, - ).toBe("starting"); - expect(harness.sendTurn).not.toHaveBeenCalled(); - - yield* Deferred.succeed(releaseStart, undefined); - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); - }), - ); + }), + ); + expect(harness.sendTurn).not.toHaveBeenCalled(); - effectIt.effect("settles a failed provider startup and allows a clean retry", () => - Effect.gen(function* () { - let failStartup = true; - const harness = yield* Effect.promise(() => - createHarness({ - startSessionEffect: (session) => - failStartup - ? Effect.fail( - new ProviderAdapterRequestError({ - provider: "codex", - method: "thread.start", - detail: "deterministic startup failure", - }), - ) - : Effect.succeed(session), - }), - ); - const now = "2026-01-01T00:00:00.000Z"; + await harness.startReactor(); + await harness.drain(); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + input: "resume this exact pending request", + interactionMode: "plan", + }); + await harness.startReactor(); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + }); - yield* harness.engine.dispatch({ + it("continues an interrupted running turn without replaying its user message", async () => { + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-recovery", + }; + const threadId = ThreadId.make("thread-1"); + const harness = await createHarness({ + deferReactorStart: true, + threadModelSelection: modelSelection, + providerBindings: [ + { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + adapterKey: "codex", + runtimeMode: "full-access", + status: "stopped", + resumeCursor: { threadId: "provider-thread-resume" }, + runtimePayload: { + cwd: "/tmp/persisted-recovery-cwd", + modelSelection, + interactionMode: "plan", + activeTurnId: null, + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("provider-turn-before-restart"), + shutdownAt: "2026-01-01T00:00:01.000Z", + }), + }, + lastSeenAt: "2026-01-01T00:00:01.000Z", + }, + ], + }); + await Effect.runPromise( + harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-failure"), - threadId: ThreadId.make("thread-1"), + commandId: CommandId.make("cmd-running-before-restart"), + threadId, message: { - messageId: asMessageId("user-message-provider-failure"), + messageId: asMessageId("running-user-message"), role: "user", - text: "fail once", + text: "the original request must not be replayed", attachments: [], }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }); + modelSelection, + interactionMode: "plan", + runtimeMode: "full-access", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("server:running-before-restart"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: asTurnId("orchestration-turn-before-restart"), + lastError: null, + updatedAt: "2026-01-01T00:00:00.500Z", + }, + createdAt: "2026-01-01T00:00:00.500Z", + }), + ); + + await harness.startReactor(); + await harness.drain(); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: "/tmp/persisted-recovery-cwd", + modelSelection, + resumeCursor: { threadId: "provider-thread-resume" }, + runtimeMode: "full-access", + providerInstanceId: ProviderInstanceId.make("codex"), + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toEqual({ + threadId, + input: RESTART_RECOVERY_CONTINUATION_INSTRUCTION, + attachments: [], + modelSelection, + interactionMode: "plan", + }); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + const oldTurn = (await harness.readTurns(threadId)).find( + (turn) => turn.turnId === asTurnId("orchestration-turn-before-restart"), + ); + expect(oldTurn?.state).toBe("interrupted"); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "the original request must not be replayed", + ]); + expect(harness.providerBindings.get(threadId)?.runtimePayload).toMatchObject({ + restartRecovery: null, + interactionMode: "plan", + }); + }); + + it("does not resume settled turns from stale recovery bindings", async () => { + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const markerThreadId = ThreadId.make("thread-1"); + const legacyThreadId = ThreadId.make("thread-settled-legacy"); + const markerTurnId = asTurnId("turn-settled-marker"); + const legacyTurnId = asTurnId("turn-settled-legacy"); + const harness = await createHarness({ + deferReactorStart: true, + providerBindings: [ + { + threadId: markerThreadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + status: "stopped", + resumeCursor: { threadId: "provider-thread-marker" }, + runtimePayload: { + modelSelection, + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: markerTurnId, + shutdownAt: "2026-01-01T00:00:02.000Z", + }), + }, + lastSeenAt: "2026-01-01T00:00:02.000Z", + }, + { + threadId: legacyThreadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + status: "running", + resumeCursor: { threadId: "provider-thread-legacy" }, + runtimePayload: { + modelSelection, + activeTurnId: legacyTurnId, + }, + lastSeenAt: "2026-01-01T00:00:02.000Z", + }, + ], + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-create-settled-legacy-thread"), + threadId: legacyThreadId, + projectId: asProjectId("project-1"), + title: "Settled legacy thread", + modelSelection, + interactionMode: "default", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); - yield* Effect.promise(() => - waitFor(async () => { - const readModel = await harness.readModel(); - return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session - ?.status === "error" - ); + const settleTurn = async (threadId: ThreadId, turnId: TurnId, suffix: string) => { + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-settled-turn-${suffix}`), + threadId, + message: { + messageId: asMessageId(`message-settled-${suffix}`), + role: "user", + text: "already finished", + attachments: [], + }, + modelSelection, + interactionMode: "default", + runtimeMode: "full-access", + createdAt: "2026-01-01T00:00:00.500Z", + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(`server:settled-running-${suffix}`), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", }), ); - let readModel = yield* Effect.promise(() => harness.readModel()); - let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.lastError).toContain("deterministic startup failure"); - expect(harness.sendTurn).not.toHaveBeenCalled(); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(`server:settled-ready-${suffix}`), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.500Z", + }, + createdAt: "2026-01-01T00:00:01.500Z", + }), + ); + }; - failStartup = false; - yield* harness.engine.dispatch({ + await settleTurn(markerThreadId, markerTurnId, "marker"); + await settleTurn(legacyThreadId, legacyTurnId, "legacy"); + expect( + (await harness.readTurns(markerThreadId)).find((turn) => turn.turnId === markerTurnId)?.state, + ).toBe("completed"); + expect( + (await harness.readTurns(legacyThreadId)).find((turn) => turn.turnId === legacyTurnId)?.state, + ).toBe("completed"); + + await harness.startReactor(); + await harness.drain(); + + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + for (const threadId of [markerThreadId, legacyThreadId]) { + expect(harness.providerBindings.get(threadId)).toMatchObject({ + status: "stopped", + runtimePayload: { + activeTurnId: null, + restartRecovery: null, + lastRuntimeEvent: "provider.restartRecovery.skipped", + }, + }); + } + }); + + it("recovers across temporary-SQLite runtimes and does not repeat a completed recovery", async () => { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-reactor-two-runtime-"), + ); + const threadId = ThreadId.make("thread-1"); + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const persistedBindings = new Map(); + const first = await createHarness({ baseDir, providerBindingsMap: persistedBindings }); + await Effect.runPromise( + first.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-retry"), - threadId: ThreadId.make("thread-1"), + commandId: CommandId.make("cmd-two-runtime-original-turn"), + threadId, message: { - messageId: asMessageId("user-message-provider-retry"), + messageId: asMessageId("two-runtime-user-message"), role: "user", - text: "retry", + text: "finish this after the server restarts", attachments: [], }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + modelSelection, + interactionMode: "default", runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(() => first.sendTurn.mock.calls.length === 1); + await Effect.runPromise( + first.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("server:two-runtime-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: asTurnId("orchestration-turn-two-runtime"), + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, createdAt: "2026-01-01T00:00:01.000Z", - }); + }), + ); + await Effect.runPromise( + first.directoryUpsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + status: "stopped", + resumeCursor: { threadId: "durable-provider-thread" }, + runtimePayload: { + cwd: "/tmp/provider-project", + modelSelection, + interactionMode: "default", + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("provider-turn-two-runtime"), + shutdownAt: "2026-01-01T00:00:02.000Z", + }), + }, + }), + ); - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); - readModel = yield* Effect.promise(() => harness.readModel()); - thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.status).toBe("starting"); - expect(thread?.session?.lastError).toBeNull(); - }), - ); + await Effect.runPromise(Scope.close(scope!, Exit.void)); + scope = null; + await runtime!.dispose(); + runtime = null; + + const second = await createHarness({ + baseDir, + providerBindingsMap: persistedBindings, + skipDefaultSetup: true, + }); + await second.drain(); + await waitFor(() => second.sendTurn.mock.calls.length === 1); + expect(second.startSession.mock.calls[0]?.[1]).toMatchObject({ + resumeCursor: { threadId: "durable-provider-thread" }, + cwd: "/tmp/provider-project", + modelSelection, + runtimeMode: "approval-required", + }); + expect(second.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: RESTART_RECOVERY_CONTINUATION_INSTRUCTION, + }); + const secondReadModel = await second.readModel(); + expect( + secondReadModel.threads + .find((thread) => thread.id === threadId) + ?.messages.map((message) => message.text), + ).toEqual(["finish this after the server restarts"]); + + await Effect.runPromise( + second.directoryUpsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + status: "running", + runtimePayload: { activeTurnId: null, restartRecovery: null }, + }), + ); + await Effect.runPromise( + second.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("server:two-runtime-recovery-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + createdAt: "2026-01-01T00:00:03.000Z", + }), + ); + + await Effect.runPromise(Scope.close(scope!, Exit.void)); + scope = null; + await runtime!.dispose(); + runtime = null; + + const third = await createHarness({ + baseDir, + providerBindingsMap: persistedBindings, + skipDefaultSetup: true, + }); + await third.drain(); + expect(third.sendTurn).not.toHaveBeenCalled(); + }); + + it("isolates restart recovery failures and continues unrelated threads", async () => { + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const failedThreadId = ThreadId.make("thread-1"); + const healthyThreadId = ThreadId.make("thread-2"); + const marker = makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("provider-turn-before-restart"), + shutdownAt: "2026-01-01T00:00:01.000Z", + }); + const makeBinding = ( + threadId: ThreadId, + resumeCursor: unknown | null, + ): ProviderRuntimeBindingWithMetadata => ({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + adapterKey: "codex", + runtimeMode: "approval-required", + status: "stopped", + resumeCursor, + runtimePayload: { + cwd: "/tmp/provider-project", + modelSelection, + interactionMode: "default", + restartRecovery: marker, + }, + lastSeenAt: "2026-01-01T00:00:01.000Z", + }); + const harness = await createHarness({ + deferReactorStart: true, + providerBindings: [ + makeBinding(failedThreadId, null), + makeBinding(healthyThreadId, { threadId: "healthy-provider-thread" }), + ], + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-create-recovery-thread-2"), + threadId: healthyThreadId, + projectId: asProjectId("project-1"), + title: "Healthy recovery", + modelSelection, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + + await harness.startReactor(); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: healthyThreadId, + input: RESTART_RECOVERY_CONTINUATION_INSTRUCTION, + }); + + const readModel = await harness.readModel(); + const failedThread = readModel.threads.find((thread) => thread.id === failedThreadId); + expect(failedThread?.session?.status).toBe("error"); + expect( + failedThread?.activities.some( + (activity) => activity.kind === "provider.turn.recovery.failed", + ), + ).toBe(true); + }); + + it("surfaces a disabled provider instance without starting recovery work", async () => { + const threadId = ThreadId.make("thread-1"); + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const harness = await createHarness({ + deferReactorStart: true, + providerInstanceEnabled: false, + providerBindings: [ + { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + status: "stopped", + resumeCursor: { threadId: "disabled-provider-thread" }, + runtimePayload: { + modelSelection, + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("disabled-provider-turn"), + shutdownAt: "2026-01-01T00:00:01.000Z", + }), + }, + lastSeenAt: "2026-01-01T00:00:01.000Z", + }, + ], + }); + + await harness.startReactor(); + await harness.drain(); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("error"); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.recovery.failed") + ?.payload, + ).toMatchObject({ detail: expect.stringContaining("disabled") }); + }); + + it("skips archived recovery candidates", async () => { + const threadId = ThreadId.make("thread-1"); + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const harness = await createHarness({ + deferReactorStart: true, + providerBindings: [ + { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + status: "stopped", + resumeCursor: { threadId: "archived-provider-thread" }, + runtimePayload: { + modelSelection, + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("archived-provider-turn"), + shutdownAt: "2026-01-01T00:00:01.000Z", + }), + }, + lastSeenAt: "2026-01-01T00:00:01.000Z", + }, + ], + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("cmd-archive-before-recovery"), + threadId, + }), + ); + + await harness.startReactor(); + await harness.drain(); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.providerBindings.get(threadId)?.runtimePayload).toMatchObject({ + restartRecovery: expect.objectContaining({ version: 1 }), + }); + }); it("generates a thread title on the first turn", async () => { const harness = await createHarness(); @@ -1724,7 +2260,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set"), @@ -1742,7 +2278,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.interrupt", commandId: CommandId.make("cmd-turn-interrupt"), @@ -1762,7 +2298,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-stale"), @@ -1780,7 +2316,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make("cmd-turn-start-stale"), @@ -1817,7 +2353,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-missing-instance"), @@ -1845,7 +2381,7 @@ describe("ProviderCommandReactor", () => { updatedAt: now, }); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make("cmd-turn-start-missing-instance"), @@ -1888,7 +2424,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval"), @@ -1906,7 +2442,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond"), @@ -1929,7 +2465,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input"), @@ -1947,7 +2483,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond"), @@ -1983,7 +2519,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval-error"), @@ -2001,7 +2537,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-approval-requested"), @@ -2022,7 +2558,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond-stale"), @@ -2078,7 +2614,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input-error"), @@ -2096,7 +2632,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-user-input-requested"), @@ -2129,7 +2665,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond-stale"), @@ -2178,7 +2714,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-stop"), @@ -2197,7 +2733,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.stop", commandId: CommandId.make("cmd-session-stop"), @@ -2215,4 +2751,55 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + effectIt.effect( + "stops the provider session when the stop is requested after the thread was archived", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-archive-stop"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + // Mirrors the archive flow: the archive commits first, so the + // reactor resolves the stop against an already-archived thread. + yield* harness.engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("cmd-archive-before-stop"), + threadId: ThreadId.make("thread-1"), + }); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-session-stop-after-archive"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.stopSession.mock.calls.length === 1)); + expect(harness.stopSession.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + }); + yield* Effect.promise(() => harness.drain()); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.archivedAt).not.toBeNull(); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.activeTurnId).toBeNull(); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b6bff8c766a..b6588cbea37 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -9,6 +9,7 @@ import { type OrchestrationSession, ThreadId, type ProviderSession, + type ProviderInteractionMode, type RuntimeMode, type TurnId, } from "@t3tools/contracts"; @@ -16,6 +17,8 @@ import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shar import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -26,12 +29,29 @@ import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; +import { + increment, + orchestrationEventsProcessedTotal, + providerTurnRecoveriesTotal, +} from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; +import { + readPersistedProviderCwd, + readPersistedProviderInteractionMode, + readPersistedProviderModelSelection, + readProviderRestartRecoveryCandidate, + type ProviderRestartRecoveryCandidate, +} from "../../provider/ProviderRestartRecovery.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; +import { + ProviderSessionDirectory, + type ProviderRuntimeBindingWithMetadata, +} from "../../provider/Services/ProviderSessionDirectory.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { @@ -87,6 +107,10 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; +const STARTUP_RECOVERY_CONCURRENCY = 4; + +export const RESTART_RECOVERY_CONTINUATION_INSTRUCTION = + "The server restarted while you were working. Inspect the conversation and current workspace state, verify which side effects from the interrupted turn already happened, and continue the unfinished work safely. Do not repeat completed work or assume an earlier tool call failed merely because its response is absent."; export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -190,7 +214,9 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projectionTurnRepository = yield* ProjectionTurnRepository; const providerService = yield* ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory; const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; @@ -204,6 +230,9 @@ const make = Effect.gen(function* () { timeToLive: HANDLED_TURN_START_KEY_TTL, lookup: () => Effect.succeed(true), }); + const startupReconciliationDone = yield* Deferred.make(); + const recoveredThreadIds = new Set(); + const interruptedRecoveryThreadIds = new Set(); const hasHandledTurnStartRecently = (key: string) => Cache.getOption(handledTurnStartKeys, key).pipe( @@ -218,6 +247,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly kind: | "provider.turn.start.failed" + | "provider.turn.recovery.failed" | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" | "provider.user-input.respond.failed" @@ -998,34 +1028,353 @@ const make = Effect.gen(function* () { const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( event: Extract, ) { - const thread = yield* resolveThread(event.payload.threadId); - if (!thread) { + // Session stops are resolved through an archived-inclusive context query: + // the archive flow dispatches the stop after the thread disappears from + // the active-only shell/detail queries, so resolving through those would + // silently leak the provider session. + const context = yield* projectionSnapshotQuery + .getSessionStopContextById(event.payload.threadId) + .pipe(Effect.map(Option.getOrUndefined)); + if (!context) { return; } const now = event.payload.createdAt; - if (thread.session && thread.session.status !== "stopped") { - yield* providerService.stopSession({ threadId: thread.id }); + if (context.session && context.session.status !== "stopped") { + yield* providerService.stopSession({ threadId: context.threadId }); } yield* setThreadSession({ - threadId: thread.id, + threadId: context.threadId, session: { - threadId: thread.id, + threadId: context.threadId, status: "stopped", - providerName: thread.session?.providerName ?? null, - ...(thread.session?.providerInstanceId !== undefined - ? { providerInstanceId: thread.session.providerInstanceId } + providerName: context.session?.providerName ?? null, + ...(context.session?.providerInstanceId !== undefined + ? { providerInstanceId: context.session.providerInstanceId } : {}), - runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + runtimeMode: context.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, activeTurnId: null, - lastError: thread.session?.lastError ?? null, + lastError: context.session?.lastError ?? null, updatedAt: now, }, createdAt: now, }); }); + const setRecoveryFailureState = Effect.fn("setRecoveryFailureState")(function* (input: { + readonly binding: ProviderRuntimeBindingWithMetadata; + readonly detail: string; + readonly createdAt: string; + }) { + const thread = yield* resolveThread(input.binding.threadId); + if (!thread) return; + yield* setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: "error", + providerName: input.binding.provider, + ...(input.binding.providerInstanceId !== undefined + ? { providerInstanceId: input.binding.providerInstanceId } + : {}), + runtimeMode: input.binding.runtimeMode ?? thread.runtimeMode, + activeTurnId: null, + lastError: input.detail, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + + const recoverInterruptedTurn = Effect.fn("recoverInterruptedTurn")(function* (input: { + readonly binding: ProviderRuntimeBindingWithMetadata; + readonly candidate: ProviderRestartRecoveryCandidate; + }) { + const { binding, candidate } = input; + if (recoveredThreadIds.has(binding.threadId)) { + yield* increment(providerTurnRecoveriesTotal, { + outcome: "skipped", + reason: "duplicate-in-boot", + provider: binding.provider, + }); + return; + } + recoveredThreadIds.add(binding.threadId); + + const thread = yield* resolveThread(binding.threadId); + if (!thread) { + yield* Effect.logInfo("provider turn restart recovery skipped", { + threadId: binding.threadId, + provider: binding.provider, + reason: "thread-missing-archived-or-deleted", + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "skipped", + reason: "inactive-thread", + provider: binding.provider, + }); + return; + } + + const createdAt = DateTime.formatIso(yield* DateTime.now); + const projectedTurns = yield* projectionTurnRepository.listByThreadId({ + threadId: binding.threadId, + }); + const projectedCandidateTurn = + candidate.interruptedProviderTurnId === null + ? undefined + : projectedTurns.find((turn) => turn.turnId === candidate.interruptedProviderTurnId); + const latestProjectedTurn = projectedTurns.findLast((turn) => turn.turnId !== null); + const projectedRecoveryTurn = projectedCandidateTurn ?? latestProjectedTurn; + if (projectedRecoveryTurn?.state === "completed" || projectedRecoveryTurn?.state === "error") { + yield* providerSessionDirectory + .upsert({ + threadId: binding.threadId, + provider: binding.provider, + ...(binding.providerInstanceId !== undefined + ? { providerInstanceId: binding.providerInstanceId } + : {}), + ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}), + status: "stopped", + runtimePayload: { + activeTurnId: null, + restartRecovery: null, + lastRuntimeEvent: "provider.restartRecovery.skipped", + lastRuntimeEventAt: createdAt, + }, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("stale provider restart recovery intent was not cleared", { + threadId: binding.threadId, + cause: Cause.pretty(cause), + }), + ), + ); + yield* Effect.logInfo("provider turn restart recovery skipped", { + threadId: binding.threadId, + provider: binding.provider, + reason: "projected-turn-settled", + projectedTurnId: projectedRecoveryTurn.turnId, + projectedTurnState: projectedRecoveryTurn.state, + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "skipped", + reason: "projected-turn-settled", + provider: binding.provider, + }); + return; + } + interruptedRecoveryThreadIds.add(thread.id); + + const recover = Effect.gen(function* () { + const runtimeMode = binding.runtimeMode ?? thread.runtimeMode; + // This lifecycle transition settles the concrete old projection row as + // interrupted before validation or replacement work can proceed. + yield* setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: "interrupted", + providerName: binding.provider, + ...(binding.providerInstanceId !== undefined + ? { providerInstanceId: binding.providerInstanceId } + : {}), + runtimeMode, + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + + const providerInstanceId = binding.providerInstanceId; + if (providerInstanceId === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Persisted provider binding for thread '${binding.threadId}' has no provider instance id.`, + }); + } + if (binding.resumeCursor === null || binding.resumeCursor === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Cannot recover thread '${binding.threadId}' because no provider resume cursor is persisted.`, + }); + } + + const instanceInfo = yield* providerService.getInstanceInfo(providerInstanceId); + if (!instanceInfo.enabled) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Provider instance '${providerInstanceId}' is disabled in T3 Code settings.`, + }); + } + if (instanceInfo.driverKind !== binding.provider) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Persisted provider instance '${providerInstanceId}' now uses driver '${instanceInfo.driverKind}', not '${binding.provider}'.`, + }); + } + + const persistedModelSelection = readPersistedProviderModelSelection(binding.runtimePayload); + const modelSelection = persistedModelSelection ?? thread.modelSelection; + if (modelSelection.instanceId !== providerInstanceId) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Persisted model selection references provider instance '${modelSelection.instanceId}', but the recoverable session belongs to '${providerInstanceId}'.`, + }); + } + const interactionMode: ProviderInteractionMode = + readPersistedProviderInteractionMode(binding.runtimePayload) ?? thread.interactionMode; + const project = yield* resolveProject(thread.projectId); + const cwd = + readPersistedProviderCwd(binding.runtimePayload) ?? + resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [] }); + + const session = yield* providerService.startSession(thread.id, { + threadId: thread.id, + provider: binding.provider, + providerInstanceId, + ...(cwd !== undefined ? { cwd } : {}), + modelSelection, + resumeCursor: binding.resumeCursor, + runtimeMode, + }); + yield* setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: mapProviderSessionStatusToOrchestrationStatus(session.status), + providerName: session.provider, + providerInstanceId, + runtimeMode, + activeTurnId: null, + lastError: session.lastError ?? null, + updatedAt: session.updatedAt, + }, + createdAt, + }); + + const replacement = yield* providerService.sendTurn({ + threadId: thread.id, + input: RESTART_RECOVERY_CONTINUATION_INSTRUCTION, + attachments: [], + modelSelection, + interactionMode, + }); + + // ProviderService clears this in the accepted sendTurn transaction. The + // explicit write keeps the reconciliation invariant local and obvious. + yield* providerSessionDirectory.upsert({ + threadId: thread.id, + provider: binding.provider, + providerInstanceId, + runtimeMode, + status: "running", + ...(replacement.resumeCursor !== undefined + ? { resumeCursor: replacement.resumeCursor } + : {}), + runtimePayload: { + activeTurnId: replacement.turnId, + modelSelection, + interactionMode, + restartRecovery: null, + lastRuntimeEvent: "provider.restartRecovery.accepted", + lastRuntimeEventAt: DateTime.formatIso(yield* DateTime.now), + }, + }); + + yield* Effect.logInfo("provider turn restart recovery accepted", { + threadId: thread.id, + provider: binding.provider, + providerInstanceId, + interruptedProviderTurnId: candidate.interruptedProviderTurnId, + replacementProviderTurnId: replacement.turnId, + recoverySource: candidate.source, + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "continued", + source: candidate.source, + provider: binding.provider, + }); + }); + + yield* recover.pipe( + Effect.catchCause((cause) => { + const detail = formatFailureDetail(cause); + const persistFailureState = + binding.providerInstanceId === undefined + ? Effect.void + : providerSessionDirectory + .upsert({ + threadId: binding.threadId, + provider: binding.provider, + providerInstanceId: binding.providerInstanceId, + ...(binding.runtimeMode !== undefined + ? { runtimeMode: binding.runtimeMode } + : {}), + status: "error", + runtimePayload: { + activeTurnId: null, + lastError: detail, + lastRuntimeEvent: "provider.restartRecovery.failed", + lastRuntimeEventAt: createdAt, + }, + }) + .pipe( + Effect.catchCause((persistenceCause) => + Effect.logWarning("provider restart recovery failure state was not persisted", { + threadId: binding.threadId, + cause: Cause.pretty(persistenceCause), + }), + ), + ); + return persistFailureState.pipe( + Effect.andThen(setRecoveryFailureState({ binding, detail, createdAt })), + Effect.andThen( + appendProviderFailureActivity({ + threadId: binding.threadId, + kind: "provider.turn.recovery.failed", + summary: "Provider turn recovery failed", + detail, + turnId: thread.latestTurn?.turnId ?? null, + createdAt, + }), + ), + Effect.andThen( + Effect.logWarning("provider turn restart recovery failed", { + threadId: binding.threadId, + provider: binding.provider, + providerInstanceId: binding.providerInstanceId, + recoverySource: candidate.source, + cause: Cause.pretty(cause), + }), + ), + Effect.andThen( + increment(providerTurnRecoveriesTotal, { + outcome: "failed", + source: candidate.source, + provider: binding.provider, + }), + ), + Effect.catchCause((reportingCause) => + Effect.logWarning("provider turn restart recovery failure reporting failed", { + threadId: binding.threadId, + cause: Cause.pretty(reportingCause), + originalCause: Cause.pretty(cause), + }), + ), + ); + }), + ); + }); + const processDomainEvent = Effect.fn("processDomainEvent")(function* ( event: ProviderIntentEvent, ) { @@ -1084,6 +1433,135 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); + const reconcileStartup = Effect.fn("reconcileStartup")(function* () { + const bindings = yield* providerSessionDirectory.listBindings().pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider restart recovery failed to list persisted bindings", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + const recoveryCandidates = bindings.flatMap((binding) => { + const candidate = readProviderRestartRecoveryCandidate({ + runtimePayload: binding.runtimePayload, + status: binding.status, + lastSeenAt: binding.lastSeenAt, + }); + return candidate === undefined ? [] : [{ binding, candidate }]; + }); + const pendingTurnStarts = yield* projectionTurnRepository.listPendingTurnStarts().pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider restart recovery failed to list pending turn starts", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([])), + ), + ); + + yield* Effect.logInfo("provider restart reconciliation candidates loaded", { + interruptedTurnCandidates: recoveryCandidates.length, + pendingTurnStartCandidates: pendingTurnStarts.length, + }); + if (recoveryCandidates.length > 0) { + yield* increment( + providerTurnRecoveriesTotal, + { outcome: "candidate", recoveryKind: "interrupted-turn" }, + recoveryCandidates.length, + ); + } + + yield* Effect.forEach(recoveryCandidates, recoverInterruptedTurn, { + concurrency: STARTUP_RECOVERY_CONCURRENCY, + discard: true, + }); + + const pendingWithoutInterruptedRecovery = pendingTurnStarts.filter( + (pending) => !interruptedRecoveryThreadIds.has(pending.threadId), + ); + if (pendingWithoutInterruptedRecovery.length === 0) return; + + const persistedEvents = yield* Stream.runCollect( + orchestrationEngine.readEvents(0, Number.MAX_SAFE_INTEGER), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.catchCause((cause) => + Effect.logWarning("provider restart recovery failed to read persisted turn starts", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + const turnStartEventsByPendingKey = new Map< + string, + Extract + >(); + for (const event of persistedEvents) { + if (event.type !== "thread.turn-start-requested") continue; + turnStartEventsByPendingKey.set( + `${event.payload.threadId}\u0000${event.payload.messageId}\u0000${event.payload.createdAt}`, + event, + ); + } + + yield* Effect.forEach( + pendingWithoutInterruptedRecovery, + (pending) => + Effect.gen(function* () { + const thread = yield* resolveThread(pending.threadId); + if (!thread) { + yield* Effect.logInfo("pending provider turn start reconciliation skipped", { + threadId: pending.threadId, + messageId: pending.messageId, + reason: "thread-missing-archived-or-deleted", + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "skipped", + recoveryKind: "pending-start", + reason: "inactive-thread", + }); + return; + } + const event = turnStartEventsByPendingKey.get( + `${pending.threadId}\u0000${pending.messageId}\u0000${pending.requestedAt}`, + ); + if (event === undefined) { + yield* appendProviderFailureActivity({ + threadId: pending.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start recovery failed", + detail: `Persisted turn start event for user message '${pending.messageId}' could not be found.`, + turnId: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "failed", + recoveryKind: "pending-start", + reason: "event-missing", + }); + return; + } + + yield* worker.enqueue(event); + yield* Effect.logInfo("pending provider turn start replay enqueued", { + threadId: pending.threadId, + messageId: pending.messageId, + eventId: event.eventId, + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "replayed", + recoveryKind: "pending-start", + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("pending provider turn start reconciliation failed", { + threadId: pending.threadId, + messageId: pending.messageId, + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: STARTUP_RECOVERY_CONCURRENCY, discard: true }, + ); + }); + const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( @@ -1101,12 +1579,23 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent), ); + yield* reconcileStartup().pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider restart reconciliation failed", { + cause: Cause.pretty(cause), + }), + ), + Effect.ensuring(Deferred.succeed(startupReconciliationDone, undefined).pipe(Effect.ignore)), + Effect.forkScoped, + ); }); return { start, - drain: worker.drain, + drain: Deferred.await(startupReconciliationDone).pipe(Effect.andThen(worker.drain)), } satisfies ProviderCommandReactorShape; }); -export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); +export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make).pipe( + Layer.provide(ProjectionTurnRepositoryLive), +); diff --git a/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts b/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts new file mode 100644 index 00000000000..a6f69d45a6a --- /dev/null +++ b/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts @@ -0,0 +1,681 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ProviderInstanceId, + ThreadId, + WorktreeLifecycleError, + type ModelSelection, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +import { ServerConfig } from "../../config.ts"; +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + ProjectionThreadRepository, + type ProjectionThread, +} from "../../persistence/Services/ProjectionThreads.ts"; +import { ProjectSetupScriptRunner } from "../../project/ProjectSetupScriptRunner.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { WorktreeLifecycle } from "../Services/WorktreeLifecycle.ts"; +import { WorktreeLifecycleLive } from "./WorktreeLifecycle.ts"; + +const isWorktreeLifecycleError = Schema.is(WorktreeLifecycleError); + +const now = "2026-03-01T00:00:00.000Z"; +const projectId = ProjectId.make("project-1"); +const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +}; + +interface HarnessRefs { + workspaceRoot: string; + readonly stopSessionCalls: Array; + readonly terminalCloseCalls: Array; + readonly setupScriptCalls: Array<{ readonly threadId: string; readonly worktreePath: string }>; + removeWorktreeStarted: Deferred.Deferred | null; + removeWorktreeRelease: Deferred.Deferred | null; +} + +const makeRefs = (): HarnessRefs => ({ + workspaceRoot: "", + stopSessionCalls: [], + terminalCloseCalls: [], + setupScriptCalls: [], + removeWorktreeStarted: null, + removeWorktreeRelease: null, +}); + +const emptyVcsStatus = { + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const gitWorkflowFromDriver = (refs: HarnessRefs) => + Layer.unwrap( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + return Layer.mock(GitWorkflowService)({ + createWorktree: (input) => driver.createWorktree(input), + removeWorktree: (input) => + Effect.gen(function* () { + if (refs.removeWorktreeStarted) { + yield* Deferred.succeed(refs.removeWorktreeStarted, undefined); + } + if (refs.removeWorktreeRelease) { + yield* Deferred.await(refs.removeWorktreeRelease); + } + yield* driver.removeWorktree(input); + }), + }); + }), + ); + +const makeTestLayer = (refs: HarnessRefs) => + WorktreeLifecycleLive.pipe( + Layer.provideMerge(ProjectionThreadRepositoryLive), + Layer.provideMerge( + Layer.mock(ProjectionSnapshotQuery)({ + getProjectShellById: (id) => + Effect.sync(() => + id === projectId && refs.workspaceRoot.length > 0 + ? Option.some({ + id: projectId, + title: "Project 1", + workspaceRoot: refs.workspaceRoot, + repositoryIdentity: null, + defaultModelSelection: modelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + }) + : Option.none(), + ), + }), + ), + Layer.provideMerge( + Layer.mock(ProviderService)({ + stopSession: ({ threadId }) => + Effect.sync(() => { + refs.stopSessionCalls.push(threadId); + }), + }), + ), + Layer.provideMerge( + Layer.mock(TerminalManager.TerminalManager)({ + close: (input) => + Effect.sync(() => { + refs.terminalCloseCalls.push(input.threadId); + }), + }), + ), + Layer.provideMerge( + Layer.mock(VcsStatusBroadcaster)({ + refreshStatus: () => Effect.succeed(emptyVcsStatus), + }), + ), + Layer.provideMerge( + Layer.mock(ProjectSetupScriptRunner)({ + runForThread: (input) => + Effect.sync(() => { + refs.setupScriptCalls.push({ + threadId: input.threadId, + worktreePath: input.worktreePath, + }); + return { status: "no-script" } as const; + }), + }), + ), + Layer.provideMerge(gitWorkflowFromDriver(refs)), + Layer.provideMerge( + GitVcsDriver.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-worktree-lifecycle-config-" }), + ), + ), + ), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ); + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "WorktreeLifecycle.test.git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +/** Creates a real repo with an initial commit plus a feature-branch worktree. */ +const setupRepoWithWorktree = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const repoDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-lifecycle-repo-", + }); + yield* driver.initRepo({ cwd: repoDir }); + yield* git(repoDir, ["config", "user.email", "test@test.com"]); + yield* git(repoDir, ["config", "user.name", "Test"]); + yield* fileSystem.writeFileString(pathService.join(repoDir, "README.md"), "# test\n"); + yield* git(repoDir, ["add", "."]); + yield* git(repoDir, ["-c", "commit.gpgsign=false", "commit", "-m", "initial commit"]); + const initialBranch = yield* git(repoDir, ["branch", "--show-current"]); + + const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-lifecycle-wt-", + }); + const worktreePath = pathService.join(worktreesDir, "feature-1"); + yield* driver.createWorktree({ + cwd: repoDir, + refName: initialBranch, + newRefName: "feature-1", + path: worktreePath, + }); + + return { repoDir, worktreePath, initialBranch, branch: "feature-1" }; +}); + +const makeThreadRow = (input: { + readonly threadId: string; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly archivedAt?: string | null; + readonly deletedAt?: string | null; +}): ProjectionThread => ({ + threadId: ThreadId.make(input.threadId), + projectId, + title: `Thread ${input.threadId}`, + modelSelection, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: input.branch, + worktreePath: input.worktreePath, + latestTurnId: null, + createdAt: now, + updatedAt: now, + archivedAt: input.archivedAt ?? null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: input.deletedAt ?? null, +}); + +const seedThreads = (rows: ReadonlyArray) => + Effect.gen(function* () { + const repository = yield* ProjectionThreadRepository; + yield* Effect.forEach(rows, (row) => repository.upsert(row), { discard: true }); + }); + +const runWithHarness = ( + refs: HarnessRefs, + body: Effect.Effect< + A, + E, + | WorktreeLifecycle + | ProjectionThreadRepository + | GitVcsDriver.GitVcsDriver + | FileSystem.FileSystem + | Path.Path + | Scope.Scope + >, +) => Effect.scoped(body).pipe(Effect.provide(makeTestLayer(refs))); + +it.effect("preview returns a candidate for the only active worktree thread", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.deepStrictEqual(preview.candidate, { + worktreePath: repo.worktreePath, + branch: repo.branch, + }); + }), + ); +}); + +it.effect("preview returns no candidate when another active thread shares the path", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + makeThreadRow({ threadId: "t2", branch: repo.branch, worktreePath: repo.worktreePath }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.isNull(preview.candidate); + }), + ); +}); + +it.effect("archived and deleted siblings do not prevent a candidate", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + makeThreadRow({ + threadId: "t2", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + makeThreadRow({ + threadId: "t3", + branch: repo.branch, + worktreePath: repo.worktreePath, + deletedAt: now, + }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.deepStrictEqual(preview.candidate, { + worktreePath: repo.worktreePath, + branch: repo.branch, + }); + }), + ); +}); + +it.effect("preview treats different normalized spellings of the path as one worktree", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + // Same worktree spelled with a trailing separator. + makeThreadRow({ + threadId: "t2", + branch: repo.branch, + worktreePath: `${repo.worktreePath}/`, + }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.isNull(preview.candidate); + }), + ); +}); + +it.effect("preview returns no candidate without a retained branch", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: null, worktreePath: repo.worktreePath }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.isNull(preview.candidate); + }), + ); +}); + +it.effect( + "cleanup force-removes a dirty worktree, preserves the branch, and stops archived runtimes", + () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + makeThreadRow({ + threadId: "t2", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + ]); + // Make the worktree dirty so a non-forced removal would fail. + yield* fileSystem.writeFileString( + pathService.join(repo.worktreePath, "dirty.txt"), + "uncommitted\n", + ); + + const result = yield* lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }); + assert.strictEqual(result.status, "removed"); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), false); + // The branch survives removal so the worktree stays restorable. + const branches = yield* git(repo.repoDir, ["branch", "--list", repo.branch]); + assert.include(branches, repo.branch); + // Every archived reference had its runtime stopped. + assert.sameMembers(refs.stopSessionCalls, ["t1", "t2"]); + assert.sameMembers(refs.terminalCloseCalls, ["t1", "t2"]); + }), + ); + }, +); + +it.effect("cleanup is retained when a reference becomes active after preview", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + // Unarchived (active) sibling appeared between preview and cleanup. + makeThreadRow({ threadId: "t2", branch: repo.branch, worktreePath: repo.worktreePath }), + ]); + + const result = yield* lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }); + assert.strictEqual(result.status, "retained-active"); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), true); + assert.lengthOf(refs.stopSessionCalls, 0); + }), + ); +}); + +it.effect("cleanup is retained when the target thread itself became active again", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + ]); + + const result = yield* lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }); + assert.strictEqual(result.status, "retained-active"); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), true); + }), + ); +}); + +it.effect("cleanup reports an already-missing path without failing", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + ]); + yield* fileSystem.remove(repo.worktreePath, { recursive: true }); + + const result = yield* lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }); + assert.strictEqual(result.status, "already-missing"); + }), + ); +}); + +it.effect("cleanup failures surface as a typed WorktreeLifecycleError", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + // Points at an existing directory that is not a registered worktree, so + // `git worktree remove` fails. + const bogusPath = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-lifecycle-bogus-", + }); + yield* seedThreads([ + makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: bogusPath, + archivedAt: now, + }), + ]); + + const result = yield* Effect.flip( + lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }), + ); + assert.isTrue(isWorktreeLifecycleError(result)); + assert.strictEqual(result.operation, "cleanup"); + }), + ); +}); + +it.effect("unarchive restoration recreates a missing worktree and runs the setup script", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repository = yield* ProjectionThreadRepository; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + const row = makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }); + yield* seedThreads([row]); + yield* driver.removeWorktree({ cwd: repo.repoDir, path: repo.worktreePath, force: true }); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), false); + + let committed = false; + const commit = repository.upsert({ ...row, archivedAt: null }).pipe( + Effect.tap(() => + Effect.sync(() => { + committed = true; + }), + ), + Effect.as({ sequence: 1 }), + ); + const result = yield* lifecycle.restoreThreadWorktree({ threadId: row.threadId }, commit); + assert.deepStrictEqual(result, { sequence: 1 }); + assert.isTrue(committed); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), true); + const worktrees = yield* git(repo.repoDir, ["worktree", "list", "--porcelain"]); + assert.include(worktrees, repo.worktreePath); + const branchInWorktree = yield* git(repo.worktreePath, ["branch", "--show-current"]); + assert.strictEqual(branchInWorktree, repo.branch); + assert.deepStrictEqual(refs.setupScriptCalls, [ + { threadId: "t1", worktreePath: repo.worktreePath }, + ]); + }), + ); +}); + +it.effect("unarchive restoration is a no-op when the worktree still exists", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + const row = makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }); + yield* seedThreads([row]); + + let committed = false; + const commit = Effect.sync(() => { + committed = true; + return { sequence: 1 }; + }); + yield* lifecycle.restoreThreadWorktree({ threadId: row.threadId }, commit); + assert.isTrue(committed); + assert.lengthOf(refs.setupScriptCalls, 0); + }), + ); +}); + +it.effect("failed recreation leaves the thread archived and never commits", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + const row = makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }); + yield* seedThreads([row]); + yield* driver.removeWorktree({ cwd: repo.repoDir, path: repo.worktreePath, force: true }); + // Delete the branch so recreation cannot succeed. + yield* git(repo.repoDir, ["branch", "-D", repo.branch]); + + let committed = false; + const commit = Effect.sync(() => { + committed = true; + return { sequence: 1 }; + }); + const error = yield* Effect.flip( + lifecycle.restoreThreadWorktree({ threadId: row.threadId }, commit), + ); + assert.isTrue(isWorktreeLifecycleError(error)); + assert.strictEqual((error as WorktreeLifecycleError).operation, "restore"); + assert.isFalse(committed); + assert.lengthOf(refs.setupScriptCalls, 0); + }), + ); +}); + +it.effect("concurrent cleanup and unarchive restoration serialize on the worktree lock", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repository = yield* ProjectionThreadRepository; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + const row = makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }); + yield* seedThreads([row]); + + refs.removeWorktreeStarted = yield* Deferred.make(); + refs.removeWorktreeRelease = yield* Deferred.make(); + + const cleanupFiber = yield* lifecycle + .cleanupThreadWorktree({ threadId: row.threadId }) + .pipe(Effect.forkScoped); + // Cleanup holds the per-path lock and is mid-removal. + yield* Deferred.await(refs.removeWorktreeStarted); + + const commit = repository.upsert({ ...row, archivedAt: null }).pipe(Effect.asVoid); + const restoreFiber = yield* lifecycle + .restoreThreadWorktree({ threadId: row.threadId }, commit) + .pipe(Effect.forkScoped); + yield* Deferred.succeed(refs.removeWorktreeRelease, undefined); + + const cleanupResult = yield* Fiber.join(cleanupFiber); + yield* Fiber.join(restoreFiber); + + // Restoration only ran after the removal finished: it saw the missing + // path and recreated the worktree instead of skipping restoration + // against a doomed checkout. + assert.strictEqual(cleanupResult.status, "removed"); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), true); + const restored = yield* repository.getById({ threadId: row.threadId }); + assert.isTrue(Option.isSome(restored) && restored.value.archivedAt === null); + assert.deepStrictEqual(refs.setupScriptCalls, [ + { threadId: "t1", worktreePath: repo.worktreePath }, + ]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/WorktreeLifecycle.ts b/apps/server/src/orchestration/Layers/WorktreeLifecycle.ts new file mode 100644 index 00000000000..046d64b1da1 --- /dev/null +++ b/apps/server/src/orchestration/Layers/WorktreeLifecycle.ts @@ -0,0 +1,395 @@ +import { WorktreeLifecycleError, type ThreadId } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import * as Cause from "effect/Cause"; +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 Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { + ProjectionThreadRepository, + type ProjectionThread, + type ProjectionThreadWorktreeReference, +} from "../../persistence/Services/ProjectionThreads.ts"; +import { ProjectSetupScriptRunner } from "../../project/ProjectSetupScriptRunner.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { WorktreeLifecycle, type WorktreeLifecycleShape } from "../Services/WorktreeLifecycle.ts"; + +// Best-effort cleanup steps must not surface their own error types through +// the lifecycle API: swallow and log everything except interruption. +const swallowCauseUnlessInterrupted = (input: { + readonly effect: Effect.Effect; + readonly message: string; + readonly threadId: ThreadId; +}): Effect.Effect => + input.effect.pipe( + Effect.asVoid, + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause as Cause.Cause); + } + return Effect.logDebug(input.message, { + threadId: input.threadId, + cause: Cause.pretty(cause), + }); + }), + ); + +function nonEmptyOrNull(value: string | null): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +const make = Effect.gen(function* () { + const threadRepository = yield* ProjectionThreadRepository; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const gitWorkflow = yield* GitWorkflowService; + const providerService = yield* ProviderService; + const terminalManager = yield* TerminalManager.TerminalManager; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; + const setupScriptRunner = yield* ProjectSetupScriptRunner; + const fileSystem = yield* FileSystem.FileSystem; + + // Conditional removal and unarchive restoration serialize on the same + // per-normalized-path lock so a cleanup can never interleave with a + // restoration of the same worktree. + const pathLocksRef = yield* SynchronizedRef.make(new Map()); + const getPathSemaphore = (pathKey: string) => + SynchronizedRef.modifyEffect(pathLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(pathKey), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(pathKey, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + const withWorktreePathLock = (pathKey: string, effect: Effect.Effect) => + Effect.flatMap(getPathSemaphore(pathKey), (semaphore) => semaphore.withPermit(effect)); + + const lifecycleError = (input: { + readonly operation: string; + readonly threadId: ThreadId; + readonly detail: string; + readonly cause?: unknown; + }) => + new WorktreeLifecycleError({ + operation: input.operation, + threadId: input.threadId, + detail: input.detail, + ...(input.cause !== undefined ? { cause: input.cause } : {}), + }); + + const loadNondeletedThreadRow = (operation: string, threadId: ThreadId) => + threadRepository.getById({ threadId }).pipe( + Effect.mapError((cause) => + lifecycleError({ operation, threadId, detail: "Failed to load thread state.", cause }), + ), + Effect.map(Option.filter((row: ProjectionThread) => row.deletedAt === null)), + ); + + const listWorktreeReferences = (operation: string, threadId: ThreadId) => + threadRepository.listWorktreeReferences().pipe( + Effect.mapError((cause) => + lifecycleError({ + operation, + threadId, + detail: "Failed to load worktree references.", + cause, + }), + ), + ); + + const referencesForPath = ( + references: ReadonlyArray, + normalizedPath: string, + ) => + references.filter( + (reference) => normalizeProjectPathForComparison(reference.worktreePath) === normalizedPath, + ); + + const requireProjectWorkspaceRoot = (input: { + readonly operation: string; + readonly thread: ProjectionThread; + }) => + projectionSnapshotQuery.getProjectShellById(input.thread.projectId).pipe( + Effect.mapError((cause) => + lifecycleError({ + operation: input.operation, + threadId: input.thread.threadId, + detail: "Failed to load the thread's project.", + cause, + }), + ), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + lifecycleError({ + operation: input.operation, + threadId: input.thread.threadId, + detail: "The thread's project was not found.", + }), + ), + onSome: (project) => Effect.succeed(project.workspaceRoot), + }), + ), + ); + + const stopThreadRuntime = (threadId: ThreadId) => + swallowCauseUnlessInterrupted({ + effect: providerService.stopSession({ threadId }), + message: "worktree cleanup skipped provider session stop", + threadId, + }).pipe( + Effect.andThen( + swallowCauseUnlessInterrupted({ + effect: terminalManager.close({ threadId }), + message: "worktree cleanup skipped terminal close", + threadId, + }), + ), + ); + + const refreshVcsStatus = (workspaceRoot: string) => + vcsStatusBroadcaster + .refreshStatus(workspaceRoot) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + + const worktreePathExists = (input: { + readonly operation: string; + readonly threadId: ThreadId; + readonly worktreePath: string; + }) => + fileSystem.exists(input.worktreePath).pipe( + Effect.mapError((cause) => + lifecycleError({ + operation: input.operation, + threadId: input.threadId, + detail: `Failed to inspect the worktree path ${input.worktreePath}.`, + cause, + }), + ), + ); + + const previewCleanup: WorktreeLifecycleShape["previewCleanup"] = Effect.fn( + "WorktreeLifecycle.previewCleanup", + )(function* ({ threadId }) { + const operation = "cleanup preview"; + const threadRow = yield* loadNondeletedThreadRow(operation, threadId); + if (Option.isNone(threadRow)) { + return { candidate: null }; + } + const thread = threadRow.value; + const worktreePath = nonEmptyOrNull(thread.worktreePath); + const branch = nonEmptyOrNull(thread.branch); + // Only an active thread with a restorable worktree (path + retained + // branch) can produce a candidate. + if (thread.archivedAt !== null || worktreePath === null || branch === null) { + return { candidate: null }; + } + + const normalizedPath = normalizeProjectPathForComparison(worktreePath); + const references = yield* listWorktreeReferences(operation, threadId); + const sharedWithActiveThread = referencesForPath(references, normalizedPath).some( + (reference) => reference.threadId !== threadId && reference.archivedAt === null, + ); + + return { + candidate: sharedWithActiveThread ? null : { worktreePath, branch }, + }; + }); + + const cleanupThreadWorktree: WorktreeLifecycleShape["cleanupThreadWorktree"] = Effect.fn( + "WorktreeLifecycle.cleanupThreadWorktree", + )(function* ({ threadId }) { + const operation = "cleanup"; + const threadRow = yield* loadNondeletedThreadRow(operation, threadId); + if (Option.isNone(threadRow)) { + return yield* lifecycleError({ operation, threadId, detail: "The thread was not found." }); + } + const thread = threadRow.value; + const worktreePath = nonEmptyOrNull(thread.worktreePath); + if (worktreePath === null) { + return yield* lifecycleError({ + operation, + threadId, + detail: "The thread has no worktree path recorded.", + }); + } + // A thread that was unarchived between confirmation and cleanup is an + // active reference again, not an error. + if (thread.archivedAt === null) { + return { status: "retained-active", worktreePath } as const; + } + + const workspaceRoot = yield* requireProjectWorkspaceRoot({ operation, thread }); + const normalizedPath = normalizeProjectPathForComparison(worktreePath); + + return yield* withWorktreePathLock( + normalizedPath, + Effect.gen(function* () { + // Mandatory recheck under the lock: another client may have + // unarchived or attached a thread since the preview. + const references = referencesForPath( + yield* listWorktreeReferences(operation, threadId), + normalizedPath, + ); + if (references.some((reference) => reference.archivedAt === null)) { + return { status: "retained-active", worktreePath } as const; + } + + // Every remaining reference is archived; make sure none of them + // still runs a provider session or terminal inside the worktree. + const threadIdsToStop = new Set([ + threadId, + ...references.map((reference) => reference.threadId), + ]); + yield* Effect.forEach(threadIdsToStop, stopThreadRuntime, { discard: true }); + + const exists = yield* worktreePathExists({ operation, threadId, worktreePath }); + if (!exists) { + return { status: "already-missing", worktreePath } as const; + } + + yield* gitWorkflow + .removeWorktree({ cwd: workspaceRoot, path: worktreePath, force: true }) + .pipe( + Effect.mapError((cause) => + lifecycleError({ + operation, + threadId, + detail: `Failed to remove the worktree at ${worktreePath}: ${cause.detail}`, + cause, + }), + ), + ); + + // Compensate for unavoidable external races: if an active reference + // appeared while the removal ran, recreate the worktree from the + // retained branch at the original path. + const postRemovalReferences = referencesForPath( + yield* listWorktreeReferences(operation, threadId), + normalizedPath, + ); + if (postRemovalReferences.some((reference) => reference.archivedAt === null)) { + const branch = nonEmptyOrNull(thread.branch); + if (branch === null) { + return yield* lifecycleError({ + operation, + threadId, + detail: `The worktree at ${worktreePath} was removed while another thread became active, and no branch is recorded to recreate it.`, + }); + } + yield* gitWorkflow + .createWorktree({ cwd: workspaceRoot, refName: branch, path: worktreePath }) + .pipe( + Effect.mapError((cause) => + lifecycleError({ + operation, + threadId, + detail: `Failed to recreate the worktree at ${worktreePath} after another thread became active.`, + cause, + }), + ), + ); + yield* refreshVcsStatus(workspaceRoot); + return { status: "retained-active", worktreePath } as const; + } + + yield* refreshVcsStatus(workspaceRoot); + return { status: "removed", worktreePath } as const; + }), + ); + }); + + const restoreThreadWorktree: WorktreeLifecycleShape["restoreThreadWorktree"] = ( + { threadId }: { readonly threadId: ThreadId }, + commitUnarchive: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const operation = "restore"; + const threadRow = yield* loadNondeletedThreadRow(operation, threadId); + if (Option.isNone(threadRow)) { + // Let the dispatch path produce its canonical "unknown thread" error. + return yield* commitUnarchive; + } + const thread = threadRow.value; + const worktreePath = nonEmptyOrNull(thread.worktreePath); + if (worktreePath === null) { + return yield* commitUnarchive; + } + + const normalizedPath = normalizeProjectPathForComparison(worktreePath); + return yield* withWorktreePathLock( + normalizedPath, + Effect.gen(function* () { + const exists = yield* worktreePathExists({ operation, threadId, worktreePath }); + if (exists) { + return yield* commitUnarchive; + } + + const branch = nonEmptyOrNull(thread.branch); + if (branch === null) { + return yield* lifecycleError({ + operation, + threadId, + detail: `The worktree at ${worktreePath} is missing and no branch is recorded to recreate it. The thread stays archived.`, + }); + } + + const workspaceRoot = yield* requireProjectWorkspaceRoot({ operation, thread }); + yield* gitWorkflow + .createWorktree({ cwd: workspaceRoot, refName: branch, path: worktreePath }) + .pipe( + Effect.mapError((cause) => + lifecycleError({ + operation, + threadId, + detail: `Failed to recreate the worktree at ${worktreePath} from branch '${branch}': ${cause.detail}. The thread stays archived.`, + cause, + }), + ), + ); + yield* refreshVcsStatus(workspaceRoot); + + const result = yield* commitUnarchive; + + // The checkout was recreated from scratch, so dependencies and + // generated files are gone: run the worktree setup script again. + yield* swallowCauseUnlessInterrupted({ + effect: setupScriptRunner.runForThread({ + threadId, + projectId: thread.projectId, + worktreePath, + }), + message: "worktree restoration could not start the setup script", + threadId, + }); + + return result; + }), + ); + }); + + return { + previewCleanup, + cleanupThreadWorktree, + restoreThreadWorktree, + } satisfies WorktreeLifecycleShape; +}); + +export const WorktreeLifecycleLive = Layer.effect(WorktreeLifecycle, make); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 23b291d8778..18e9e89d5d3 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -12,6 +12,7 @@ import type { OrchestrationProject, OrchestrationProjectShell, OrchestrationReadModel, + OrchestrationSession, OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, @@ -42,6 +43,11 @@ export interface ProjectionThreadCheckpointContext { readonly checkpoints: ReadonlyArray; } +export interface ProjectionSessionStopContext { + readonly threadId: ThreadId; + readonly session: OrchestrationSession | null; +} + export interface ProjectionFullThreadDiffContext { readonly threadId: ThreadId; readonly projectId: ProjectId; @@ -145,6 +151,18 @@ export interface ProjectionSnapshotQueryShape { toTurnCount: number, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read the narrow context needed to stop a thread's provider session. + * + * Unlike the shell/detail queries this includes archived, nondeleted + * threads: session-stop commands dispatched as part of archiving must + * still resolve the thread after `archivedAt` is set, or the provider + * session would never be stopped. + */ + readonly getSessionStopContextById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read a single active thread shell row by id. */ @@ -168,6 +186,29 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadDetailSnapshot: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Cursor-paginated load of a thread's older activities (lazy-load / infinite + * scroll). Returns the page of activities immediately older than the provided + * sequence or unsequenced activity cursor, ascending, plus whether older ones + * remain. + */ + readonly getThreadActivitiesPage: ( + input: OrchestrationGetThreadActivitiesInput, + ) => Effect.Effect; + + /** + * Read a thread's lifecycle markers regardless of its deleted/archived + * state. Lets callers that got no active row distinguish a thread that was + * deleted or archived (permanent) from one whose projection row does not + * exist (possibly not projected yet). + */ + readonly getThreadLifecycleById: ( + threadId: ThreadId, + ) => Effect.Effect< + Option.Option<{ readonly deletedAt: string | null; readonly archivedAt: string | null }>, + ProjectionRepositoryError + >; } /** diff --git a/apps/server/src/orchestration/Services/WorktreeLifecycle.ts b/apps/server/src/orchestration/Services/WorktreeLifecycle.ts new file mode 100644 index 00000000000..79037509b60 --- /dev/null +++ b/apps/server/src/orchestration/Services/WorktreeLifecycle.ts @@ -0,0 +1,73 @@ +/** + * WorktreeLifecycle - Server-authoritative worktree cleanup and restoration. + * + * Owns the archive-time cleanup decision (preview + conditional removal) and + * the unarchive-time restoration of a missing worktree. All operations are + * keyed by thread id so clients never make the final safety decision about + * which path is removed or recreated. + * + * @module WorktreeLifecycle + */ +import type { + ThreadId, + WorktreeCleanupPreviewResult, + WorktreeCleanupResult, + WorktreeLifecycleError, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorktreeCleanupThreadInput { + readonly threadId: ThreadId; +} + +/** + * WorktreeLifecycleShape - Service API for thread worktree lifecycle. + */ +export interface WorktreeLifecycleShape { + /** + * Decide whether archiving this thread would orphan its worktree. + * + * Returns a candidate only when the thread is active, has both a branch + * and a worktree path (so removal stays restorable), and no other active + * nondeleted thread references the same normalized path. Clients use this + * only to decide whether to show the confirmation prompt. + */ + readonly previewCleanup: ( + input: WorktreeCleanupThreadInput, + ) => Effect.Effect; + + /** + * Force-remove the archived thread's worktree if it is still orphaned. + * + * Re-reads all nondeleted references under a per-path lock before + * removing, stops provider sessions and closes terminals that could still + * use the path, keeps the branch, and refreshes VCS status. Returns a + * structured status instead of failing when the worktree is retained or + * already missing. + */ + readonly cleanupThreadWorktree: ( + input: WorktreeCleanupThreadInput, + ) => Effect.Effect; + + /** + * Recreate a missing worktree before committing a thread unarchive. + * + * Runs `commitUnarchive` unchanged when no restoration is needed. When the + * recorded worktree path is missing, the worktree is recreated from the + * retained branch at the original path while holding the same per-path + * lock used by cleanup, and the commit is dispatched under that lock. If + * recreation fails the commit never runs, so the thread stays archived. + */ + readonly restoreThreadWorktree: ( + input: WorktreeCleanupThreadInput, + commitUnarchive: Effect.Effect, + ) => Effect.Effect; +} + +/** + * WorktreeLifecycle - Service tag for thread worktree lifecycle operations. + */ +export class WorktreeLifecycle extends Context.Service()( + "t3/orchestration/Services/WorktreeLifecycle", +) {} diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4763f565653..f2c329d211b 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -195,4 +195,85 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(updated?.snoozedAt, null); }), ); + + it.effect("lists nondeleted worktree references including archived rows", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM projection_threads`; + + const baseRow = { + projectId: ProjectId.make("project-worktrees"), + title: "Worktree thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature-1", + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + } as const; + + yield* threads.upsert({ + ...baseRow, + threadId: ThreadId.make("thread-active-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: null, + deletedAt: null, + }); + yield* threads.upsert({ + ...baseRow, + threadId: ThreadId.make("thread-archived-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: "2026-03-25T00:00:00.000Z", + deletedAt: null, + }); + yield* threads.upsert({ + ...baseRow, + threadId: ThreadId.make("thread-deleted-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: null, + deletedAt: "2026-03-25T00:00:00.000Z", + }); + yield* threads.upsert({ + ...baseRow, + threadId: ThreadId.make("thread-no-worktree"), + worktreePath: null, + archivedAt: null, + deletedAt: null, + }); + + const references = yield* threads.listWorktreeReferences(); + assert.deepStrictEqual( + references.map((reference) => ({ + threadId: reference.threadId, + worktreePath: reference.worktreePath, + archivedAt: reference.archivedAt, + })), + [ + { + threadId: ThreadId.make("thread-active-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: null, + }, + { + threadId: ThreadId.make("thread-archived-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: "2026-03-25T00:00:00.000Z", + }, + ], + ); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 7e86d49eac3..7d00747427b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -12,6 +12,7 @@ import { ListProjectionThreadsByProjectInput, ProjectionThread, ProjectionThreadRepository, + ProjectionThreadWorktreeReference, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; import { ModelSelection } from "@t3tools/contracts"; @@ -166,6 +167,23 @@ const makeProjectionThreadRepository = Effect.gen(function* () { `, }); + const listProjectionThreadWorktreeReferenceRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadWorktreeReference, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + worktree_path AS "worktreePath", + archived_at AS "archivedAt" + FROM projection_threads + WHERE deleted_at IS NULL + AND worktree_path IS NOT NULL + ORDER BY created_at ASC, thread_id ASC + `, + }); + const deleteProjectionThreadRow = SqlSchema.void({ Request: DeleteProjectionThreadInput, execute: ({ threadId }) => @@ -195,11 +213,19 @@ const makeProjectionThreadRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), ); + const listWorktreeReferences: ProjectionThreadRepositoryShape["listWorktreeReferences"] = () => + listProjectionThreadWorktreeReferenceRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadRepository.listWorktreeReferences:query"), + ), + ); + return { upsert, getById, listByProjectId, deleteById, + listWorktreeReferences, } satisfies ProjectionThreadRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index bd57a4eaa30..bc59d3ed45f 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -169,6 +169,26 @@ const makeProjectionTurnRepository = Effect.gen(function* () { `, }); + const listPendingProjectionTurns = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionPendingTurnStart, + execute: () => + sql` + SELECT + thread_id AS "threadId", + pending_message_id AS "messageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + requested_at AS "requestedAt" + FROM projection_turns + WHERE turn_id IS NULL + AND state = 'pending' + AND pending_message_id IS NOT NULL + AND checkpoint_turn_count IS NULL + ORDER BY requested_at ASC, thread_id ASC + `, + }); + const listProjectionTurnsByThread = SqlSchema.findAll({ Request: ListProjectionTurnsByThreadInput, Result: ProjectionTurnDbRowSchema, @@ -288,6 +308,13 @@ const makeProjectionTurnRepository = Effect.gen(function* () { ), ); + const listPendingTurnStarts: ProjectionTurnRepositoryShape["listPendingTurnStarts"] = () => + listPendingProjectionTurns(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.listPendingTurnStarts:query"), + ), + ); + const deletePendingTurnStartByThreadId: ProjectionTurnRepositoryShape["deletePendingTurnStartByThreadId"] = (input) => clearPendingProjectionTurnsByThread(input).pipe( @@ -341,6 +368,7 @@ const makeProjectionTurnRepository = Effect.gen(function* () { upsertByTurnId, replacePendingTurnStart, getPendingTurnStartByThreadId, + listPendingTurnStarts, deletePendingTurnStartByThreadId, listByThreadId, getByTurnId, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 056425ae886..6ae93624ebc 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -63,6 +63,14 @@ export const ListProjectionThreadsByProjectInput = Schema.Struct({ }); export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; +export const ProjectionThreadWorktreeReference = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + worktreePath: Schema.String, + archivedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionThreadWorktreeReference = typeof ProjectionThreadWorktreeReference.Type; + /** * ProjectionThreadRepositoryShape - Service API for projected thread records. */ @@ -96,6 +104,19 @@ export interface ProjectionThreadRepositoryShape { readonly deleteById: ( input: DeleteProjectionThreadInput, ) => Effect.Effect; + + /** + * List every nondeleted thread row that references a worktree path. + * + * Soft-deleted rows are excluded so they never count as worktree + * references; archived rows are included so callers can distinguish + * archived from active references. Path comparison is left to callers so + * they can apply the shared normalization helper. + */ + readonly listWorktreeReferences: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; } /** diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index f3d5d5e4706..faf70bce24a 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -128,6 +128,15 @@ export interface ProjectionTurnRepositoryShape { input: GetProjectionPendingTurnStartInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Lists every pending-start placeholder across active reconciliation scope. + * Callers must still resolve the owning thread and discard archived/deleted rows. + */ + readonly listPendingTurnStarts: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Deletes only pending-start placeholder rows (`turnId = null`) for a thread and leaves concrete turn rows untouched. */ diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849d..8a7267c6ec4 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -19,6 +19,8 @@ type ChildProcessCommand = { readonly args: ReadonlyArray; readonly options: { readonly shell?: boolean | string; + readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; }; }; @@ -80,6 +82,24 @@ const runWith = ); describe("runProcess", () => { + it.effect("can launch with an exact non-extending environment", () => { + const environment = { PATH: "/project/bin", KEEP: "value" }; + const spawner = makeSpawner((command) => + Effect.sync(() => { + expect(command.options.env).toEqual(environment); + expect(command.options.extendEnv).toBe(false); + return makeHandle({ stdout: "ok" }); + }), + ); + + return runWith(spawner)({ + command: "/project/bin/direnv", + args: ["export", "json"], + env: environment, + extendEnv: false, + }); + }); + it.effect("collects stdout through an injected ChildProcessSpawner", () => Effect.gen(function* () { const spawner = makeSpawner((command) => diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index c1ee2b2cb0c..bbc11a2e6cd 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -23,6 +23,7 @@ export interface ProcessRunInput { readonly spawnCwd?: string | undefined; readonly timeout?: Duration.Input | undefined; readonly env?: NodeJS.ProcessEnv | undefined; + readonly extendEnv?: boolean | undefined; readonly stdin?: string | undefined; readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; @@ -290,7 +291,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; - const extendEnv = input.env !== undefined; + const extendEnv = input.env === undefined ? false : (input.extendEnv ?? true); const spawnCommand = yield* resolveSpawnCommand( input.command, input.args, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079..1d94f1a0025 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -42,8 +42,10 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }); const makeTerminalManagerLayer = ( diff --git a/apps/server/src/provider/DirenvEnvironment.test.ts b/apps/server/src/provider/DirenvEnvironment.test.ts new file mode 100644 index 00000000000..34dc696cba9 --- /dev/null +++ b/apps/server/src/provider/DirenvEnvironment.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +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 { ChildProcessSpawner } from "effect/unstable/process"; + +import * as ProcessRunner from "../processRunner.ts"; +import { DirenvEnvironment, DirenvEnvironmentError, layer } from "./DirenvEnvironment.ts"; + +const successfulOutput = ( + stdout: string, + stderr = "", + code = 0, +): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr, + code: ChildProcessSpawner.ExitCode(code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, +}); + +function testLayer(run: ProcessRunner.ProcessRunner["Service"]["run"]) { + return layer.pipe( + Layer.provide(Layer.succeed(ProcessRunner.ProcessRunner, { run })), + Layer.provideMerge(NodeServices.layer), + ); +} + +const makeDirenvExecutable = Effect.fn("makeDirenvExecutable")(function* (directory: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDirectory = path.join(directory, "bin"); + const executable = path.join(binDirectory, "direnv"); + yield* fileSystem.makeDirectory(binDirectory, { recursive: true }); + yield* fileSystem.writeFileString(executable, "#!/bin/sh\nexit 0\n"); + yield* fileSystem.chmod(executable, 0o755); + return { binDirectory, executable }; +}); + +/** A temp project with an `.envrc` and a fake direnv on PATH. */ +const setupDirenvProject = Effect.fn("setupDirenvProject")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-" }); + const envrcPath = path.join(cwd, ".envrc"); + yield* fileSystem.writeFileString(envrcPath, "export VALUE=next\n"); + const { binDirectory, executable } = yield* makeDirenvExecutable(cwd); + return { cwd, envrcPath, binDirectory, executable }; +}); + +describe("DirenvEnvironment", () => { + describe("new worktree approval", () => { + it.effect("approves the exact .envrc in a newly created worktree", () => { + const run = vi.fn(() => + Effect.succeed(successfulOutput("")), + ); + return Effect.gen(function* () { + const { cwd, envrcPath, binDirectory, executable } = yield* setupDirenvProject(); + const environment = { PATH: binDirectory, KEEP: "value" }; + const direnvEnvironment = yield* DirenvEnvironment; + + yield* direnvEnvironment.allow({ cwd, environment }); + + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]?.[0]).toMatchObject({ + command: executable, + args: ["allow", envrcPath], + cwd, + env: environment, + extendEnv: false, + }); + }).pipe(Effect.provide(testLayer(run))); + }); + + it.effect("does not approve an ancestor .envrc outside the new worktree", () => { + const run = vi.fn(); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-direnv-worktree-parent-", + }); + const cwd = path.join(parent, "worktree"); + yield* fileSystem.makeDirectory(cwd); + yield* fileSystem.writeFileString(path.join(parent, ".envrc"), "export VALUE=parent\n"); + const direnvEnvironment = yield* DirenvEnvironment; + + yield* direnvEnvironment.allow({ cwd, environment: { PATH: "/not-used" } }); + + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(run))); + }); + }); + + it.effect( + "returns the environment unchanged without inspecting PATH when no .envrc exists", + () => { + const run = vi.fn(); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-none-" }); + const environment = { PATH: "/not-used", KEEP: "value" }; + const resolver = yield* DirenvEnvironment; + + expect(yield* resolver.resolve({ cwd, environment })).toBe(environment); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(run))); + }, + ); + + it.effect("discovers a parent-directory .envrc and runs direnv in the requested cwd", () => { + const run = vi.fn(() => + Effect.succeed(successfulOutput("{}")), + ); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-parent-" }); + const cwd = path.join(root, "nested", "project"); + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, ".envrc"), "export PROJECT=parent\n"); + const { binDirectory, executable } = yield* makeDirenvExecutable(root); + const environment = { PATH: binDirectory }; + const resolver = yield* DirenvEnvironment; + + expect(yield* resolver.resolve({ cwd, environment })).toEqual(environment); + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]?.[0]).toMatchObject({ + command: executable, + args: ["export", "json"], + cwd, + env: environment, + extendEnv: false, + }); + }).pipe(Effect.provide(testLayer(run))); + }); + + it.effect( + "returns the environment unchanged when .envrc exists but direnv is unavailable", + () => { + const run = vi.fn(); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-missing-" }); + yield* fileSystem.writeFileString(path.join(cwd, ".envrc"), "export VALUE=next\n"); + const environment = { PATH: "", KEEP: "value" }; + const resolver = yield* DirenvEnvironment; + + expect(yield* resolver.resolve({ cwd, environment })).toBe(environment); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(run))); + }, + ); + + it.effect("applies additions, overrides, and removals from a successful export", () => { + const run = vi.fn(() => + Effect.succeed( + successfulOutput(JSON.stringify({ ADDED: "new", OVERRIDDEN: "direnv", REMOVED: null })), + ), + ); + return Effect.gen(function* () { + const { cwd, binDirectory } = yield* setupDirenvProject(); + const resolver = yield* DirenvEnvironment; + + expect( + yield* resolver.resolve({ + cwd, + environment: { + PATH: binDirectory, + OVERRIDDEN: "provider-instance", + REMOVED: "host", + PRESERVED: "yes", + }, + }), + ).toEqual({ + PATH: binDirectory, + ADDED: "new", + OVERRIDDEN: "direnv", + PRESERVED: "yes", + }); + }).pipe(Effect.provide(testLayer(run))); + }); + + it.effect("returns actionable stderr for a blocked .envrc", () => { + const run = vi.fn(() => + Effect.succeed( + successfulOutput( + "", + "secret-environment-value: .envrc is blocked. Run `direnv allow` to approve", + 1, + ), + ), + ); + return Effect.gen(function* () { + const { cwd, binDirectory } = yield* setupDirenvProject(); + const resolver = yield* DirenvEnvironment; + const error = yield* resolver + .resolve({ + cwd, + environment: { PATH: binDirectory, SECRET: "secret-environment-value" }, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(DirenvEnvironmentError); + expect(error.stage).toBe("execution"); + expect(error.message).toContain("direnv allow"); + expect(error.message).not.toContain("secret-environment-value"); + }).pipe(Effect.provide(testLayer(run))); + }); + + it.effect("rejects malformed or structurally invalid output without exposing stdout", () => { + let stdout = ""; + const run = vi.fn(() => + Effect.succeed(successfulOutput(stdout)), + ); + return Effect.gen(function* () { + const { cwd, binDirectory } = yield* setupDirenvProject(); + const resolver = yield* DirenvEnvironment; + + for (const rawStdout of ["not-json secret-value", '{"SAFE":"value","INVALID":42}']) { + stdout = rawStdout; + const error = yield* resolver + .resolve({ cwd, environment: { PATH: binDirectory } }) + .pipe(Effect.flip); + + expect(error.stage).toBe("invalid-output"); + expect(error.message).not.toContain(rawStdout); + expect(error.cause).toBeUndefined(); + } + }).pipe(Effect.provide(testLayer(run))); + }); +}); diff --git a/apps/server/src/provider/DirenvEnvironment.ts b/apps/server/src/provider/DirenvEnvironment.ts new file mode 100644 index 00000000000..9260336b541 --- /dev/null +++ b/apps/server/src/provider/DirenvEnvironment.ts @@ -0,0 +1,249 @@ +import { resolveCommandPath } from "@t3tools/shared/shell"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +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 Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as ProcessRunner from "../processRunner.ts"; +import { ProviderAdapterProcessError } from "./Errors.ts"; + +const DIRENV_MAX_OUTPUT_BYTES = 64 * 1024; +const DIRENV_TIMEOUT = "30 seconds"; +const STDERR_DETAIL_MAX_LENGTH = 2_048; +// `decodeJsonResult` diagnostics deliberately exclude the raw values, so a +// failure here never leaks direnv stdout across process and UI boundaries. +const decodeDirenvPatch = decodeJsonResult( + Schema.Record(Schema.String, Schema.NullOr(Schema.String)), +); + +export class DirenvEnvironmentError extends Schema.TaggedErrorClass()( + "DirenvEnvironmentError", + { + stage: Schema.Literals(["inspection", "execution", "invalid-output"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to resolve direnv environment during ${this.stage}: ${this.detail}`; + } +} + +export class DirenvEnvironment extends Context.Service< + DirenvEnvironment, + { + readonly allow: (input: { + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) => Effect.Effect; + readonly resolve: (input: { + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) => Effect.Effect; + } +>()("t3/provider/DirenvEnvironment") {} + +export const identityDirenvEnvironmentResolver: DirenvEnvironment["Service"]["resolve"] = (input) => + Effect.succeed(input.environment); + +export const noopDirenvEnvironmentAllow: DirenvEnvironment["Service"]["allow"] = () => Effect.void; + +/** + * Resolves a provider session environment through the optional direnv + * resolver, mapping failures into the adapter error domain. Adapters that + * are constructed without a resolver (tests) keep the base environment. + */ +export const resolveProviderSessionEnvironment = (input: { + readonly resolve: DirenvEnvironment["Service"]["resolve"] | undefined; + readonly provider: string; + readonly threadId: string; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; +}): Effect.Effect => + input.resolve === undefined + ? Effect.succeed(input.environment) + : input.resolve({ cwd: input.cwd, environment: input.environment }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: input.provider, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + +function conciseStderr(stderr: string, environment: NodeJS.ProcessEnv): string | undefined { + let redacted = stderr; + const environmentValues = Array.from( + new Set( + Object.values(environment).filter( + (value): value is string => typeof value === "string" && value.length >= 4, + ), + ), + ).sort((left, right) => right.length - left.length); + for (const value of environmentValues) { + redacted = redacted.split(value).join("[redacted]"); + } + + const normalized = redacted.replace(/\s+/g, " ").trim(); + if (normalized.length === 0) return undefined; + if (normalized.length <= STDERR_DETAIL_MAX_LENGTH) return normalized; + return `${normalized.slice(0, STDERR_DETAIL_MAX_LENGTH)}…`; +} + +export const make = Effect.fn("DirenvEnvironment.make")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const processRunner = yield* ProcessRunner.ProcessRunner; + + const envrcExists = (candidate: string) => + fileSystem.exists(candidate).pipe( + Effect.mapError( + (cause) => + new DirenvEnvironmentError({ + stage: "inspection", + detail: `Could not inspect '${candidate}'.`, + cause, + }), + ), + ); + + // Cached per PATH value for the lifetime of the service: sessions in the + // same environment would otherwise re-scan PATH on every start. + const direnvPathCache = new Map(); + const findDirenv = Effect.fn("DirenvEnvironment.findDirenv")(function* ( + environment: NodeJS.ProcessEnv, + ) { + const cacheKey = environment.PATH ?? ""; + if (direnvPathCache.has(cacheKey)) return direnvPathCache.get(cacheKey); + const direnvPath = yield* resolveCommandPath("direnv", { env: environment }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.catchTag("CommandResolutionError", () => Effect.void), + ); + direnvPathCache.set(cacheKey, direnvPath ?? undefined); + return direnvPath ?? undefined; + }); + + /** Runs direnv, or returns `undefined` when direnv is not installed. */ + const runDirenv = Effect.fn("DirenvEnvironment.runDirenv")(function* (input: { + readonly commandLabel: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) { + const direnvPath = yield* findDirenv(input.environment); + if (direnvPath === undefined) return undefined; + + const output = yield* processRunner + .run({ + command: direnvPath, + args: input.args, + cwd: input.cwd, + env: input.environment, + extendEnv: false, + maxOutputBytes: DIRENV_MAX_OUTPUT_BYTES, + timeout: DIRENV_TIMEOUT, + }) + .pipe( + Effect.mapError( + (cause) => + new DirenvEnvironmentError({ + stage: "execution", + detail: `Could not execute ${input.commandLabel}.`, + cause, + }), + ), + ); + + if (output.code !== 0) { + const stderr = conciseStderr(output.stderr, input.environment); + return yield* new DirenvEnvironmentError({ + stage: "execution", + detail: stderr + ? `${input.commandLabel} exited unsuccessfully: ${stderr}` + : `${input.commandLabel} exited unsuccessfully.`, + }); + } + return output; + }); + + /** Approves only the `.envrc` at the root of a worktree T3 just created. */ + const allow: DirenvEnvironment["Service"]["allow"] = Effect.fn("DirenvEnvironment.allow")( + function* (input) { + const cwd = path.resolve(input.cwd); + const envrcPath = path.join(cwd, ".envrc"); + if (!(yield* envrcExists(envrcPath))) return; + yield* runDirenv({ + commandLabel: "direnv allow", + args: ["allow", envrcPath], + cwd, + environment: input.environment, + }); + }, + ); + + const findNearestEnvrc = Effect.fn("DirenvEnvironment.findNearestEnvrc")(function* ( + cwd: string, + ): Effect.fn.Return { + let directory = path.resolve(cwd); + while (true) { + const candidate = path.join(directory, ".envrc"); + if (yield* envrcExists(candidate)) return candidate; + + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } + }); + + const resolve: DirenvEnvironment["Service"]["resolve"] = Effect.fn("DirenvEnvironment.resolve")( + function* (input) { + const envrcPath = yield* findNearestEnvrc(input.cwd); + if (envrcPath === undefined) return input.environment; + + const output = yield* runDirenv({ + commandLabel: "direnv", + args: ["export", "json"], + cwd: input.cwd, + environment: input.environment, + }); + if (output === undefined) return input.environment; + + const decoded = decodeDirenvPatch(output.stdout); + if (Result.isFailure(decoded)) { + return yield* new DirenvEnvironmentError({ + stage: "invalid-output", + detail: "direnv returned an invalid environment patch.", + }); + } + + const resolvedEnvironment = { ...input.environment }; + for (const [name, value] of Object.entries(decoded.success)) { + if (value === null) { + delete resolvedEnvironment[name]; + } else { + resolvedEnvironment[name] = value; + } + } + return resolvedEnvironment; + }, + ); + + return DirenvEnvironment.of({ allow, resolve }); +}); + +export const layer = Layer.effect(DirenvEnvironment, make()); + +export const layerLive = layer.pipe(Layer.provide(ProcessRunner.layer)); + +export const layerNoop = Layer.succeed(DirenvEnvironment, { + allow: noopDirenvEnvironmentAllow, + resolve: identityDirenvEnvironmentResolver, +}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2fc11311aa..7208639a086 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { @@ -84,6 +85,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type ClaudeDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path @@ -124,6 +126,7 @@ export const ClaudeDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -145,6 +148,7 @@ export const ClaudeDriver: ProviderDriver = { const adapterOptions = { instanceId, environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }; const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..f42acf2404b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -34,6 +34,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; @@ -76,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type CodexDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path @@ -119,6 +121,7 @@ export const CodexDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -158,6 +161,7 @@ export const CodexDriver: ProviderDriver = { const adapter = yield* makeCodexAdapter(effectiveConfig, { instanceId, environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 61f80489774..de56d9459d1 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -25,6 +25,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; import { buildInitialCursorProviderSnapshot, @@ -68,6 +69,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { export type CursorDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path @@ -108,6 +110,7 @@ export const CursorDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -127,6 +130,7 @@ export const CursorDriver: ProviderDriver = { const adapter = yield* makeCursorAdapter(effectiveConfig, { environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, }); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 4eb32c20c47..a9dc19ffb18 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -12,6 +12,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { makeGrokAdapter } from "../Layers/GrokAdapter.ts"; import { buildInitialGrokProviderSnapshot, @@ -51,6 +52,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver( export type GrokDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path @@ -89,6 +91,7 @@ export const GrokDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -108,6 +111,7 @@ export const GrokDriver: ProviderDriver = { const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, }); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 6342d176590..727d051a652 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -26,6 +26,7 @@ import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGen import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { checkOpenCodeProviderStatus, @@ -80,6 +81,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type OpenCodeDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | OpenCodeRuntime @@ -119,6 +121,7 @@ export const OpenCodeDriver: ProviderDriver const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -139,6 +142,7 @@ export const OpenCodeDriver: ProviderDriver const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { instanceId, environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 760f0e7fbab..5ea1758051d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -22,7 +22,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import { assert, describe, it } from "@effect/vitest"; +import { assert, describe, it, vi } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -156,6 +156,8 @@ function makeHarness(config?: { readonly baseDir?: string; readonly claudeConfig?: Partial; readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: ClaudeAdapterLiveOptions["resolveEnvironment"]; }) { const query = new FakeClaudeQuery(); let createInput: @@ -167,6 +169,8 @@ function makeHarness(config?: { const adapterOptions: ClaudeAdapterLiveOptions = { ...(config?.instanceId ? { instanceId: config.instanceId } : {}), + ...(config?.environment ? { environment: config.environment } : {}), + ...(config?.resolveEnvironment ? { resolveEnvironment: config.resolveEnvironment } : {}), createQuery: (input) => { createInput = input; return query; @@ -268,6 +272,42 @@ const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); describe("ClaudeAdapterLive", () => { + it.effect("passes the resolved environment to the SDK after applying Claude invariants", () => { + const claudeConfigDir = "/tmp/t3-claude-direnv-home"; + const resolveEnvironment = vi.fn((_input: { readonly cwd: string }) => + Effect.succeed({ + PATH: process.env.PATH, + PROVIDER_VALUE: "from-direnv", + CLAUDE_CONFIG_DIR: "/tmp/direnv-must-not-win", + }), + ); + const harness = makeHarness({ + claudeConfig: { homePath: claudeConfigDir }, + environment: { PATH: process.env.PATH, PROVIDER_VALUE: "configured" }, + resolveEnvironment, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: ThreadId.make("thread-claude-direnv"), + provider: ProviderDriverKind.make("claudeAgent"), + cwd: ".", + runtimeMode: "full-access", + }); + + assert.equal(resolveEnvironment.mock.calls[0]?.[0].cwd, process.cwd()); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.env, { + PATH: process.env.PATH, + PROVIDER_VALUE: "from-direnv", + CLAUDE_CONFIG_DIR: claudeConfigDir, + }); + assert.equal(harness.getLastCreateQueryInput()?.options.cwd, process.cwd()); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 285d9dac608..45c75faee84 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -89,6 +89,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString); @@ -216,6 +217,7 @@ interface ClaudeQueryRuntime extends AsyncIterable { export interface ClaudeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly createQuery?: (input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; @@ -1340,7 +1342,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( + const baseEnvironment = options?.environment ?? process.env; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, baseEnvironment).pipe( Effect.provideService(Path.Path, path), ); const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( @@ -3144,6 +3147,28 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + if (input.cwd !== undefined && !input.cwd.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd must be non-empty when provided.", + }); + } + const cwd = input.cwd === undefined ? undefined : path.resolve(input.cwd.trim()); + const sessionEnvironment = + cwd === undefined + ? claudeEnvironment + : yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd, + environment: baseEnvironment, + }).pipe( + Effect.flatMap((environment) => makeClaudeEnvironment(claudeSettings, environment)), + Effect.provideService(Path.Path, path), + ); + const existingContext = sessions.get(input.threadId); if (existingContext) { yield* Effect.logWarning("claude.session.replacing", { @@ -3520,7 +3545,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const queryOptions: ClaudeQueryOptions = { - ...(input.cwd ? { cwd: input.cwd } : {}), + ...(cwd ? { cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, systemPrompt: { type: "preset", preset: "claude_code" }, @@ -3541,8 +3566,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, - env: claudeEnvironment, - ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + env: sessionEnvironment, + ...(cwd ? { additionalDirectories: [cwd] } : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession ? { @@ -3569,7 +3594,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.resume.session_id": existingResumeSessionId ?? "", "claude.resume.session_at": resumeState?.resumeSessionAt ?? "", "claude.resume.turn_count": resumeState?.turnCount ?? -1, - "claude.query.cwd": input.cwd ?? "", + "claude.query.cwd": cwd ?? "", "claude.query.model": apiModelId ?? "", "claude.query.effort": effectiveEffort ?? "", "claude.query.permission_mode": permissionMode ?? "", @@ -3577,7 +3602,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.resume": existingResumeSessionId ?? "", "claude.query.session_id": newSessionId ?? "", "claude.query.include_partial_messages": true, - "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.additional_directories": cwd ? [cwd] : [], "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", @@ -3605,7 +3630,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, - ...(input.cwd ? { cwd: input.cwd } : {}), + ...(cwd ? { cwd } : {}), ...(modelSelection?.model ? { model: modelSelection.model } : {}), ...(threadId ? { threadId } : {}), resumeCursor: { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4ae654a5187..e6046e35e97 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -37,6 +37,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; +import { DirenvEnvironmentError } from "../DirenvEnvironment.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { @@ -309,6 +310,78 @@ const sessionErrorLayer = it.layer( ); sessionErrorLayer("CodexAdapterLive session errors", (it) => { + it.effect( + "uses the resolved environment and preserves an existing session on resolver failure", + () => { + const runtimeFactory = makeRuntimeFactory(); + let shouldFail = false; + const resolvedEnvironment = { + PATH: process.env.PATH, + PROVIDER_VALUE: "from-direnv", + T3CODE_CODEX_LAUNCH_ARGS: "--enable direnv-feature", + }; + const resolveEnvironment = vi.fn((_input: { readonly cwd: string }) => + shouldFail + ? Effect.fail( + new DirenvEnvironmentError({ + stage: "execution", + detail: "direnv allow is required.", + }), + ) + : Effect.succeed(resolvedEnvironment), + ); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { PATH: process.env.PATH, PROVIDER_VALUE: "configured" }, + resolveEnvironment, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("sess-direnv-preserve"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + cwd: ".", + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.strictEqual(runtime.options.environment, resolvedEnvironment); + NodeAssert.equal(runtime.options.launchArgs, "--enable direnv-feature"); + NodeAssert.equal(resolveEnvironment.mock.calls[0]?.[0].cwd, process.cwd()); + + shouldFail = true; + const error = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + cwd: ".", + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + NodeAssert.equal(error._tag, "ProviderAdapterProcessError"); + NodeAssert.match(error.detail, /direnv allow/u); + NodeAssert.equal(runtimeFactory.factory.mock.calls.length, 1); + NodeAssert.equal(runtime.closeImpl.mock.calls.length, 0); + NodeAssert.equal((yield* adapter.listSessions()).length, 1); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("maps missing adapter sessions to ProviderAdapterSessionNotFoundError", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; @@ -512,6 +585,50 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("keeps forwarding events after the start-session caller exits", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-short-lived-start-caller"); + const startCaller = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startCaller); + + const runtime = lifecycleRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-after-start-caller-exited"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "turn/started", + threadId, + turnId: asTurnId("turn-after-start-caller-exited"), + payload: { + threadId: "provider-thread-1", + turn: { + id: "turn-after-start-caller-exited", + status: "inProgress", + items: [], + error: null, + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber).pipe(Effect.timeout("1 second")); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") return; + NodeAssert.equal(firstEvent.value.type, "turn.started"); + NodeAssert.equal(firstEvent.value.turnId, "turn-after-start-caller-exited"); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 38a5887cdc3..9a46a8243ce 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -29,6 +29,7 @@ import * as Crypto from "effect/Crypto"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -50,6 +51,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -74,6 +76,7 @@ const PROVIDER = ProviderDriverKind.make("codex"); export interface CodexAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly makeRuntime?: ( options: CodexSessionRuntimeOptions, ) => Effect.Effect< @@ -1359,6 +1362,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; const serverConfig = yield* Effect.service(ServerConfig); @@ -1385,6 +1389,25 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); } + if (input.cwd !== undefined && !input.cwd.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd must be non-empty when provided.", + }); + } + const cwd = input.cwd === undefined ? process.cwd() : path.resolve(input.cwd.trim()); + const environment = + input.cwd === undefined + ? options?.environment + : yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd, + environment: options?.environment ?? process.env, + }); + const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { yield* Effect.suspend(() => stopSessionInternal(existing)); @@ -1398,10 +1421,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, - cwd: input.cwd ?? process.cwd(), + cwd, binaryPath: codexConfig.binaryPath, - launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), + launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, environment), + ...(environment ? { environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } @@ -1414,7 +1437,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(mcpSession ? { environment: { - ...(options?.environment ?? process.env), + ...(environment ?? process.env), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), }, appServerArgs: [ @@ -1462,7 +1485,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }), - ).pipe(Effect.forkChild); + ).pipe(Effect.forkIn(sessionScope)); const started = yield* runtime.start().pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a977..ffdd95efff2 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -5,7 +5,7 @@ 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"; +import { assert, it, vi } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -168,6 +168,41 @@ const cursorAdapterTestLayer = it.layer( ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("passes the resolved complete environment to the Cursor ACP child", () => + Effect.gen(function* () { + const tempDirectory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-direnv-")), + ); + const requestLogPath = NodePath.join(tempDirectory, "requests.jsonl"); + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + const resolveEnvironment = vi.fn((input) => + Effect.succeed({ + ...input.environment, + PROVIDER_VALUE: "from-direnv", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeCursorAdapter(decodeCursorSettings({ binaryPath: wrapperPath }), { + environment: { ...process.env, PROVIDER_VALUE: "configured" }, + resolveEnvironment, + }); + const threadId = ThreadId.make("cursor-direnv-thread"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: ".", + runtimeMode: "full-access", + }); + + assert.equal(resolveEnvironment.mock.calls[0]?.[0].cwd, process.cwd()); + // The wait only succeeds once the child saw the resolved environment + // (the request log path only exists inside it). + yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "initialize"); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 4dd38519c5a..e85a5737044 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -75,6 +75,7 @@ import { extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); @@ -92,6 +93,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { export interface CursorAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; /** @@ -496,7 +498,24 @@ export function makeCursorAdapter( } const cwd = path.resolve(input.cwd.trim()); - const cursorModelSelection = + const environment = yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd, + environment: options?.environment ?? process.env, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: definition.provider, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const providerModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { @@ -532,61 +551,38 @@ export function makeCursorAdapter( : cursorSettings; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeCursorAcpRuntime({ - cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); - const started = yield* Effect.gen(function* () { - yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), + const acp = yield* definition + .makeRuntime(effectiveSettings, { + environment, + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }) + .pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, turnId: ctx?.activeTurnId, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index d8c288a8292..87e3f061d36 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -67,6 +67,7 @@ import { XAiAskUserQuestionRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); @@ -81,6 +82,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { export interface GrokAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; @@ -547,6 +549,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const cwd = path.resolve(input.cwd.trim()); + const environment = yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd, + environment: options?.environment ?? process.env, + }); const grokModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); @@ -572,7 +581,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, - ...(options?.environment ? { environment: options.environment } : {}), + environment, childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabe..aa00fdae7b2 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,6 +1,6 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { it, vi } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -25,6 +25,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { DirenvEnvironmentError } from "../DirenvEnvironment.ts"; import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { OpenCodeRuntime, @@ -45,6 +46,7 @@ class OpenCodeAdapter extends Context.Service ThreadId.make(value); +const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); type MessageEntry = { info: { @@ -57,8 +59,12 @@ type MessageEntry = { const runtimeMock = { state: { startCalls: [] as string[], - sessionCreateUrls: [] as string[], - sessionCreateInputs: [] as Array>, + sessionCreateCalls: [] as Array<{ baseUrl: string; input: unknown }>, + connectCalls: [] as Array<{ + serverUrl?: string | null; + environment?: NodeJS.ProcessEnv; + cwd?: string; + }>, authHeaders: [] as Array, abortCalls: [] as string[], closeCalls: [] as string[], @@ -77,8 +83,8 @@ const runtimeMock = { }, reset() { this.state.startCalls.length = 0; - this.state.sessionCreateUrls.length = 0; - this.state.sessionCreateInputs.length = 0; + this.state.sessionCreateCalls.length = 0; + this.state.connectCalls.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; @@ -115,8 +121,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { exitCode: Effect.never, }; }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, environment, cwd }) => Effect.gen(function* () { + runtimeMock.state.connectCalls.push({ + ...(serverUrl !== undefined ? { serverUrl } : {}), + ...(environment !== undefined ? { environment } : {}), + ...(cwd !== undefined ? { cwd } : {}), + }); const url = serverUrl ?? "http://127.0.0.1:4301"; // Always register a finalizer so the closeCalls/closeError probes fire; // production attaches none for external servers. @@ -246,7 +257,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory // the layer graph reach for it — but the routing values the assertions // probe (serverUrl, serverPassword) must be threaded directly through the // decoded `OpenCodeSettings`. -const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ +const openCodeAdapterTestSettings = decodeOpenCodeSettings({ binaryPath: "fake-opencode", serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", @@ -280,7 +291,81 @@ beforeEach(() => { const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); +const makeDirenvAdapterLayer = ( + settings: typeof openCodeAdapterTestSettings, + resolveEnvironment: NonNullable< + NonNullable[1]>["resolveEnvironment"] + >, +) => + Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(settings, { + environment: { PATH: process.env.PATH, PROVIDER_VALUE: "configured" }, + resolveEnvironment, + }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ); + it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("passes the resolved environment and project cwd to a local OpenCode server", () => { + const resolvedEnvironment = { PATH: process.env.PATH, PROVIDER_VALUE: "from-direnv" }; + const resolveEnvironment = vi.fn((_input: { readonly cwd: string }) => + Effect.succeed(resolvedEnvironment), + ); + const settings = decodeOpenCodeSettings({ + binaryPath: "fake-opencode", + serverUrl: "", + }); + const adapterLayer = makeDirenvAdapterLayer(settings, resolveEnvironment); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-direnv"), + cwd: ".", + runtimeMode: "full-access", + }); + + NodeAssert.equal(resolveEnvironment.mock.calls[0]?.[0].cwd, process.cwd()); + NodeAssert.deepEqual(runtimeMock.state.connectCalls, [ + { + serverUrl: "", + environment: resolvedEnvironment, + cwd: process.cwd(), + }, + ]); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("does not resolve direnv for a configured external OpenCode server", () => { + const resolveEnvironment = vi.fn(() => + Effect.fail( + new DirenvEnvironmentError({ + stage: "execution", + detail: "must not run for external servers", + }), + ), + ); + const adapterLayer = makeDirenvAdapterLayer(openCodeAdapterTestSettings, resolveEnvironment); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-external-direnv"), + cwd: ".", + runtimeMode: "full-access", + }); + + NodeAssert.equal(resolveEnvironment.mock.calls.length, 0); + NodeAssert.equal(runtimeMock.state.connectCalls[0]?.serverUrl, "http://127.0.0.1:9999"); + }).pipe(Effect.provide(adapterLayer)); + }); + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 73c23b77e68..cf7e34a4ca5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -17,7 +17,6 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -38,6 +37,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { buildOpenCodePermissionRules, OpenCodeRuntime, @@ -240,6 +240,7 @@ interface OpenCodeSessionContext { export interface OpenCodeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -569,10 +570,7 @@ export function makeOpenCodeAdapter( const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const sameDirectory = (left: string, right: string) => - isSameOpenCodeDirectory(fileSystem, path, left, right); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -1189,8 +1187,36 @@ export function makeOpenCodeAdapter( const binaryPath = openCodeSettings.binaryPath; const serverUrl = openCodeSettings.serverUrl; const serverPassword = openCodeSettings.serverPassword; - const directory = input.cwd ?? serverConfig.cwd; - const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; + if (input.cwd !== undefined && !input.cwd.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd must be non-empty when provided.", + }); + } + const directory = + input.cwd === undefined ? serverConfig.cwd : path.resolve(input.cwd.trim()); + const environment = + input.cwd === undefined || serverUrl?.trim() + ? options?.environment + : yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd: directory, + environment: options?.environment ?? process.env, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const resumeSessionId = readOpenCodeResumeSessionId(input.resumeCursor); const existing = sessions.get(input.threadId); if (existing) { yield* stopOpenCodeContext(existing); @@ -1207,7 +1233,8 @@ export function makeOpenCodeAdapter( const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, serverUrl, - ...(options?.environment ? { environment: options.environment } : {}), + ...(environment ? { environment } : {}), + cwd: directory, }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 384de852f9b..1054816e63f 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -46,6 +46,7 @@ import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -112,6 +113,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(DirenvEnvironment.layerNoop), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -242,7 +244,10 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs // surfaced; that merged layer then provides `ServerConfig.layerTest`'s // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + const infraLayer = OpenCodeRuntimeLive.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(DirenvEnvironment.layerNoop), + ); const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3f00d3cc662..367a608dabc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -33,6 +33,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { @@ -301,24 +302,545 @@ function makeMutableServerSettingsService( }); } -it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))( - "ProviderRegistry", - (it) => { - describe("checkCodexProviderStatus", () => { - it.effect("uses the app-server account and model list for provider status", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ], +const TestLayer = Layer.mergeAll( + NodeServices.layer, + ServerSettingsModule.layerTest(), + TestHttpClientLive, + DirenvEnvironment.layerNoop, +); + +it.layer(TestLayer)("ProviderRegistry", (it) => { + describe("checkCodexProviderStatus", () => { + it.effect("uses the app-server account and model list for provider status", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "1.0.0"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "chatgpt"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); + assert.strictEqual(status.auth.email, "test@example.com"); + assert.deepStrictEqual(status.models, [ + { + slug: "gpt-live-codex", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ]); + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + }), + ); + + it.effect("passes configured launch args to the Codex provider probe", () => + Effect.gen(function* () { + let observedLaunchArgs: string | undefined; + const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); + + const status = yield* checkCodexProviderStatus(settings, (input) => { + observedLaunchArgs = input.launchArgs; + return Effect.succeed(makeCodexProbeSnapshot()); + }); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); + }), + ); + + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: true, + }, + }), + ), + ); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }), + ); + + it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "unknown"); + }), + ); + + it.effect("returns an api key label for codex api key auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "OpenAI API Key"); + }), + ); + + it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "amazonBedrock" }, + requiresOpenaiAuth: false, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "amazonBedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }), + ); + + it.effect("returns unavailable when codex is missing", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: "codex app-server", + cause: new Error("spawn codex ENOENT"), + }), + ), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); + }), + ); + + it.effect("closes the app-server probe scope when provider status times out", () => + Effect.gen(function* () { + const killCalls = yield* Ref.make(0); + const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( + Effect.provide(hangingScopedSpawnerLayer(killCalls)), + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("11 seconds"); + yield* Effect.yieldNow; + + const status = yield* Fiber.join(statusFiber); + assert.strictEqual(status.status, "error"); + assert.strictEqual( + status.message, + "Timed out while checking Codex app-server provider status.", + ); + assert.strictEqual(yield* Ref.get(killCalls), 1); + }), + ); + }); + + describe("ProviderRegistryLive", () => { + it("treats equal provider snapshots as unchanged", () => { + const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + ] as const satisfies ReadonlyArray; + + assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); + }); + + it("preserves previously discovered provider models when a refresh returns none", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("drops stale OpenCode models missing from a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + + it("retains stale OpenCode models when a refresh fails", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...previousProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode provider status has not been checked in this session yet.", + } satisfies ServerProvider; + const loggedOutProvider = { + ...previousProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "OpenCode is available, but it did not report any connected upstream providers.", + } satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:03:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const authoritativeProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:04:00.000Z", + models: [previousProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:05:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ + ...previousProvider.models, + ]); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, loggedOutProvider).models, []); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); + + const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); + const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); + + assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); + }); + + it("fills missing capabilities from the previous provider snapshot", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [], + }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it.effect("does not run provider probes during layer construction", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const initialProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: null, + message: "Checking Codex provider status.", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshCalls = yield* Ref.make(0); + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( + Effect.andThen(Effect.never), + ), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + 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( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-background-refresh-", }), ), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f..1fb1cd92c7a 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -58,6 +58,7 @@ import { import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; +import { readProviderRestartRecoveryMarker } from "../ProviderRestartRecovery.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); @@ -366,6 +367,94 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => }), ); +it.effect("graceful shutdown preserves recovery intent only for working sessions", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-recovery-")); + const dbPath = NodePath.join(tempDir, "runtime.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(persistenceLayer), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const codex = makeFakeCodexAdapter(); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter }), + ), + ), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + const scope = yield* Scope.make(); + const services = yield* Layer.build( + Layer.mergeAll(providerLayer, runtimeRepositoryLayer, directoryLayer), + ).pipe(Scope.provide(scope)); + const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(services)); + + const runningThreadId = asThreadId("thread-running-on-shutdown"); + const connectingThreadId = asThreadId("thread-connecting-on-shutdown"); + const readyThreadId = asThreadId("thread-ready-on-shutdown"); + const stoppedThreadId = asThreadId("thread-explicitly-stopped"); + for (const threadId of [runningThreadId, connectingThreadId, readyThreadId, stoppedThreadId]) { + yield* provider.startSession(threadId, { + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + } + yield* provider.sendTurn({ + threadId: runningThreadId, + input: "keep working", + interactionMode: "plan", + }); + codex.updateSession(runningThreadId, (session) => ({ + ...session, + status: "running", + activeTurnId: asTurnId("provider-turn-running"), + })); + codex.updateSession(connectingThreadId, (session) => ({ + ...session, + status: "connecting", + })); + + yield* provider.stopSession({ threadId: stoppedThreadId }); + yield* Scope.close(scope, Exit.void); + + const rows = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + return yield* repository.list(); + }).pipe(Effect.provide(runtimeRepositoryLayer)); + const byThreadId = new Map(rows.map((row) => [row.threadId, row])); + + const runningMarker = readProviderRestartRecoveryMarker( + byThreadId.get(runningThreadId)?.runtimePayload, + ); + assert.equal(runningMarker?.interruptedProviderTurnId, asTurnId("provider-turn-running")); + assert.isDefined( + readProviderRestartRecoveryMarker(byThreadId.get(connectingThreadId)?.runtimePayload), + ); + assert.isUndefined( + readProviderRestartRecoveryMarker(byThreadId.get(readyThreadId)?.runtimePayload), + ); + assert.isUndefined( + readProviderRestartRecoveryMarker(byThreadId.get(stoppedThreadId)?.runtimePayload), + ); + assert.equal(byThreadId.get(runningThreadId)?.status, "stopped"); + + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -1499,6 +1588,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); + yield* provider.sendTurn({ threadId: session.threadId, input: "hello" }); const eventsRef = yield* Ref.make>([]); const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => @@ -1512,7 +1602,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { provider: ProviderDriverKind.make("codex"), createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, - turnId: asTurnId("turn-1"), + turnId: asTurnId("turn-thread-1"), status: "completed", }; @@ -1533,6 +1623,18 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ), true, ); + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const persistedRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(persistedRuntime), true); + if (Option.isSome(persistedRuntime)) { + assert.equal(persistedRuntime.value.status, "running"); + assert.deepInclude(persistedRuntime.value.runtimePayload, { + activeTurnId: null, + lastRuntimeEvent: "turn.completed", + }); + } }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..14c7bde6cbe 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -10,7 +10,6 @@ * @module ProviderServiceLive */ import { - ModelSelection, NonNegativeInt, ThreadId, ProviderInterruptTurnInput, @@ -55,7 +54,12 @@ 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); +import { + makeProviderRestartRecoveryMarker, + readPersistedProviderActiveTurnId, + readPersistedProviderCwd, + readPersistedProviderModelSelection, +} from "../ProviderRestartRecovery.ts"; /** * Hook for tests that want to override the canonical event logger pulled @@ -123,8 +127,10 @@ function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; + readonly interactionMode?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; + readonly restartRecovery?: unknown; }, ): Record { return { @@ -133,10 +139,12 @@ function toRuntimePayloadFromSession( activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), + ...(extra?.interactionMode !== undefined ? { interactionMode: extra.interactionMode } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } : {}), + ...(extra?.restartRecovery !== undefined ? { restartRecovery: extra.restartRecovery } : {}), }; } @@ -162,6 +170,15 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function normalizeProviderCwd(cwd: string): string { + const trimmed = cwd.trim(); + return trimmed.length > 1 ? trimmed.replace(/[\\/]+$/, "") : trimmed; +} + +function providerCwdMatches(actual: string | undefined, expected: string | undefined): boolean { + if (expected === undefined) return true; + return actual !== undefined && normalizeProviderCwd(actual) === normalizeProviderCwd(expected); +} const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -238,6 +255,73 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( Effect.asVoid, ); + const persistRuntimeEventState = Effect.fn("persistRuntimeEventState")(function* ( + event: ProviderRuntimeEvent, + ) { + const binding = Option.getOrUndefined(yield* directory.getBinding(event.threadId)); + if (!binding || event.providerInstanceId === undefined) return; + if (binding.providerInstanceId !== event.providerInstanceId) { + yield* Effect.logWarning("provider runtime event ignored for stale persisted instance", { + threadId: event.threadId, + eventType: event.type, + eventProviderInstanceId: event.providerInstanceId, + bindingProviderInstanceId: binding.providerInstanceId, + }); + return; + } + + const persistedActiveTurnId = readPersistedProviderActiveTurnId(binding.runtimePayload); + const lifecycle = (() => { + switch (event.type) { + case "turn.started": + return event.turnId === undefined + ? { status: "running" as const } + : { status: "running" as const, activeTurnId: event.turnId }; + case "turn.completed": + case "turn.aborted": + if ( + persistedActiveTurnId !== undefined && + (event.turnId === undefined || event.turnId !== persistedActiveTurnId) + ) { + return undefined; + } + return { status: "running" as const, activeTurnId: null }; + case "session.exited": + return { status: "stopped" as const, activeTurnId: null }; + case "session.state.changed": + switch (event.payload.state) { + case "starting": + return { status: "starting" as const }; + case "error": + return { status: "error" as const, activeTurnId: null }; + case "stopped": + return { status: "stopped" as const, activeTurnId: null }; + case "ready": + case "waiting": + return { status: "running" as const, activeTurnId: null }; + case "running": + return { status: "running" as const }; + } + default: + return undefined; + } + })(); + if (lifecycle === undefined) return; + + yield* directory.upsert({ + threadId: event.threadId, + provider: binding.provider, + providerInstanceId: event.providerInstanceId, + ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}), + status: lifecycle.status, + runtimePayload: { + ...(lifecycle.activeTurnId !== undefined ? { activeTurnId: lifecycle.activeTurnId } : {}), + lastRuntimeEvent: event.type, + lastRuntimeEventAt: event.createdAt, + }, + }); + }); + const requireBindingInstanceId = ( operation: string, payload: { @@ -261,8 +345,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, extra?: { readonly modelSelection?: unknown; + readonly interactionMode?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; + readonly restartRecovery?: unknown; }, ) => Effect.gen(function* () { @@ -293,7 +379,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( increment(providerRuntimeEventsTotal, { provider: canonicalEvent.provider, eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + }).pipe( + Effect.andThen( + persistRuntimeEventState(canonicalEvent).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to persist provider runtime lifecycle event", { + threadId: canonicalEvent.threadId, + eventType: canonicalEvent.type, + cause, + }), + ), + ), + ), + Effect.andThen(publishRuntimeEvent(canonicalEvent)), + ), ), ); @@ -394,8 +493,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } - const persistedCwd = readPersistedCwd(input.binding.runtimePayload); - const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + const persistedCwd = readPersistedProviderCwd(input.binding.runtimePayload); + const persistedModelSelection = readPersistedProviderModelSelection( + input.binding.runtimePayload, + ); yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); const resumed = yield* adapter @@ -568,7 +669,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const effectiveCwd = input.cwd ?? (persistedBinding?.providerInstanceId === resolvedInstanceId - ? readPersistedCwd(persistedBinding.runtimePayload) + ? readPersistedProviderCwd(persistedBinding.runtimePayload) : undefined); yield* Effect.annotateCurrentSpan({ "provider.kind": resolvedProvider, @@ -688,7 +789,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + ...(input.interactionMode !== undefined + ? { interactionMode: input.interactionMode } + : {}), activeTurnId: turn.turnId, + restartRecovery: null, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: yield* nowIso, }, @@ -857,6 +962,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( status: "stopped", runtimePayload: { activeTurnId: null, + restartRecovery: null, }, }); yield* analytics.record("provider.session.stopped", { @@ -1022,12 +1128,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); yield* Effect.forEach(activeSessions, (session) => - Effect.flatMap(nowIso, (lastRuntimeEventAt) => - upsertSessionBinding(session, session.threadId, { + Effect.flatMap(nowIso, (lastRuntimeEventAt) => { + const wasWorking = session.status === "connecting" || session.status === "running"; + return upsertSessionBinding(session, session.threadId, { lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt, - }), - ), + restartRecovery: wasWorking + ? makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: session.activeTurnId, + shutdownAt: lastRuntimeEventAt, + }) + : null, + }); + }), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3843c8acbcd..e59521df49d 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -203,6 +203,7 @@ describe("ProviderSessionReaper", () => { getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: (threadId) => Effect.succeed( @@ -212,6 +213,8 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/provider/ProviderRestartRecovery.test.ts b/apps/server/src/provider/ProviderRestartRecovery.test.ts new file mode 100644 index 00000000000..c7952f547f2 --- /dev/null +++ b/apps/server/src/provider/ProviderRestartRecovery.test.ts @@ -0,0 +1,73 @@ +import { ModelSelection, ProviderInstanceId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + makeProviderRestartRecoveryMarker, + readPersistedProviderCwd, + readPersistedProviderInteractionMode, + readPersistedProviderModelSelection, + readProviderRestartRecoveryCandidate, +} from "./ProviderRestartRecovery.ts"; + +describe("ProviderRestartRecovery", () => { + it("reads typed recovery metadata and persisted restart settings", () => { + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex-work"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const marker = makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: TurnId.make("turn-interrupted"), + shutdownAt: "2026-07-22T00:00:00.000Z", + }); + const runtimePayload = { + cwd: " /tmp/project ", + modelSelection, + interactionMode: "plan", + restartRecovery: marker, + }; + + expect( + readProviderRestartRecoveryCandidate({ + runtimePayload, + status: "stopped", + lastSeenAt: "2026-07-22T00:00:01.000Z", + }), + ).toEqual({ ...marker, source: "marker" }); + expect(readPersistedProviderCwd(runtimePayload)).toBe("/tmp/project"); + expect(readPersistedProviderModelSelection(runtimePayload)).toEqual(modelSelection); + expect(readPersistedProviderInteractionMode(runtimePayload)).toBe("plan"); + }); + + it("recognizes crash-style legacy running rows with an active turn", () => { + expect( + readProviderRestartRecoveryCandidate({ + runtimePayload: { activeTurnId: TurnId.make("turn-before-crash") }, + status: "running", + lastSeenAt: "2026-07-22T00:00:00.000Z", + }), + ).toEqual({ + version: 1, + interruptedProviderTurnId: TurnId.make("turn-before-crash"), + shutdownAt: "2026-07-22T00:00:00.000Z", + source: "legacy-active-turn", + }); + }); + + it("does not recover idle, stopped, or malformed legacy rows", () => { + expect( + readProviderRestartRecoveryCandidate({ + runtimePayload: { activeTurnId: TurnId.make("turn-ready") }, + status: "stopped", + lastSeenAt: "2026-07-22T00:00:00.000Z", + }), + ).toBeUndefined(); + expect( + readProviderRestartRecoveryCandidate({ + runtimePayload: { activeTurnId: null }, + status: "running", + lastSeenAt: "2026-07-22T00:00:00.000Z", + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/ProviderRestartRecovery.ts b/apps/server/src/provider/ProviderRestartRecovery.ts new file mode 100644 index 00000000000..9aa79efe582 --- /dev/null +++ b/apps/server/src/provider/ProviderRestartRecovery.ts @@ -0,0 +1,103 @@ +import { + IsoDateTime, + ModelSelection, + ProviderInteractionMode, + TurnId, + type ProviderSessionRuntimeStatus, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export const PROVIDER_RESTART_RECOVERY_PAYLOAD_KEY = "restartRecovery"; + +export const ProviderRestartRecoveryMarker = Schema.Struct({ + version: Schema.Literal(1), + interruptedProviderTurnId: Schema.NullOr(TurnId), + shutdownAt: IsoDateTime, +}); +export type ProviderRestartRecoveryMarker = typeof ProviderRestartRecoveryMarker.Type; + +export interface ProviderRestartRecoveryCandidate extends ProviderRestartRecoveryMarker { + readonly source: "marker" | "legacy-active-turn"; +} + +const isProviderRestartRecoveryMarker = Schema.is(ProviderRestartRecoveryMarker); +const isModelSelection = Schema.is(ModelSelection); +const isProviderInteractionMode = Schema.is(ProviderInteractionMode); +const isTurnId = Schema.is(TurnId); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function makeProviderRestartRecoveryMarker(input: { + readonly interruptedProviderTurnId: TurnId | null | undefined; + readonly shutdownAt: string; +}): ProviderRestartRecoveryMarker { + return { + version: 1, + interruptedProviderTurnId: input.interruptedProviderTurnId ?? null, + shutdownAt: IsoDateTime.make(input.shutdownAt), + }; +} + +export function readProviderRestartRecoveryMarker( + runtimePayload: unknown, +): ProviderRestartRecoveryMarker | undefined { + if (!isRecord(runtimePayload)) return undefined; + const marker = runtimePayload[PROVIDER_RESTART_RECOVERY_PAYLOAD_KEY]; + return isProviderRestartRecoveryMarker(marker) ? marker : undefined; +} + +export function readProviderRestartRecoveryCandidate(input: { + readonly runtimePayload: unknown; + readonly status: ProviderSessionRuntimeStatus | undefined; + readonly lastSeenAt: string; +}): ProviderRestartRecoveryCandidate | undefined { + const marker = readProviderRestartRecoveryMarker(input.runtimePayload); + if (marker !== undefined) { + return { ...marker, source: "marker" }; + } + if (input.status !== "starting" && input.status !== "running") { + return undefined; + } + if (!isRecord(input.runtimePayload)) return undefined; + const activeTurnId = input.runtimePayload.activeTurnId; + if (!isTurnId(activeTurnId)) return undefined; + return { + version: 1, + interruptedProviderTurnId: activeTurnId, + shutdownAt: IsoDateTime.make(input.lastSeenAt), + source: "legacy-active-turn", + }; +} + +export function readPersistedProviderCwd(runtimePayload: unknown): string | undefined { + if (!isRecord(runtimePayload)) return undefined; + const cwd = runtimePayload.cwd; + if (typeof cwd !== "string") return undefined; + const trimmed = cwd.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function readPersistedProviderModelSelection( + runtimePayload: unknown, +): ModelSelection | undefined { + if (!isRecord(runtimePayload)) return undefined; + return isModelSelection(runtimePayload.modelSelection) + ? runtimePayload.modelSelection + : undefined; +} + +export function readPersistedProviderInteractionMode( + runtimePayload: unknown, +): ProviderInteractionMode | undefined { + if (!isRecord(runtimePayload)) return undefined; + return isProviderInteractionMode(runtimePayload.interactionMode) + ? runtimePayload.interactionMode + : undefined; +} + +export function readPersistedProviderActiveTurnId(runtimePayload: unknown): TurnId | undefined { + if (!isRecord(runtimePayload)) return undefined; + return isTurnId(runtimePayload.activeTurnId) ? runtimePayload.activeTurnId : undefined; +} diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e595..5b15c5394d9 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -8,9 +8,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; 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 Sink from "effect/Sink"; import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect } from "vite-plus/test"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -22,6 +25,57 @@ const mockAgentCommand = "node"; const mockAgentArgs = [mockAgentPath]; describe("AcpSessionRuntime", () => { + it.effect("passes an exact environment to the ACP child when extension is disabled", () => { + let spawnedCommand: unknown; + const spawner = ChildProcessSpawner.make((command) => { + spawnedCommand = command; + return Effect.succeed( + 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.never, + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ); + }); + const exactEnvironment = { PATH: "/project/bin", KEEP: "value" }; + + return Layer.build( + AcpSessionRuntime.layer({ + spawn: { + command: "/project/bin/agent", + args: ["acp"], + env: exactEnvironment, + extendEnv: false, + }, + cwd: "/project", + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }).pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + const command = spawnedCommand as { + readonly options: { readonly env?: NodeJS.ProcessEnv; readonly extendEnv?: boolean }; + }; + expect(command.options.env).toEqual(exactEnvironment); + expect(command.options.extendEnv).toBe(false); + }), + ), + Effect.scoped, + ); + }); + it.effect("merges custom initialize client capabilities into the ACP handshake", () => { const requestEvents: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9..23c1327d799 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -55,6 +55,8 @@ export interface AcpSpawnInput { readonly args: ReadonlyArray; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; + readonly forceKillAfter?: Duration.Input; + readonly extendEnv?: boolean; } export interface AcpSessionRuntimeOptions { @@ -329,16 +331,18 @@ export const make = ( ), ); + const extendEnv = options.spawn.extendEnv ?? true; const spawnCommand = yield* resolveSpawnCommand( options.spawn.command, options.spawn.args, - options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, + options.spawn.env ? { env: options.spawn.env, extendEnv } : {}, ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), + ...(options.spawn.env ? { env: options.spawn.env, extendEnv } : {}), + ...(options.spawn.forceKillAfter ? { forceKillAfter: options.spawn.forceKillAfter } : {}), shell: spawnCommand.shell, }), ) diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index a095fdd679b..4ceb98a7848 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -74,6 +74,21 @@ describe("buildCursorAcpSpawnInput", () => { cwd: "/tmp/project", }); }); + + it("uses a complete non-extending environment when one is supplied", () => { + expect( + buildCursorAcpSpawnInput(undefined, "/tmp/project", { + PATH: "/project/bin", + KEEP: "value", + }), + ).toEqual({ + command: "cursor-agent", + args: ["acp"], + cwd: "/tmp/project", + env: { PATH: "/project/bin", KEEP: "value" }, + extendEnv: false, + }); + }); }); describe("applyCursorAcpModelSelection", () => { diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 30203ad77b1..aa765c51be5 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -42,7 +42,7 @@ export function buildCursorAcpSpawnInput( "acp", ], cwd, - ...(environment ? { env: environment } : {}), + ...(environment ? { env: environment, extendEnv: false } : {}), }; } diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24..80e595dddb1 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -31,6 +31,7 @@ describe("buildGrokAcpSpawnInput", () => { XAI_API_KEY: "secret", GROK_OAUTH2_REFERRER: "t3code", }, + extendEnv: false, }); }); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e..3ef3cb7efe3 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -42,6 +42,7 @@ export function buildGrokAcpSpawnInput( ...environment, [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER, }, + ...(environment ? { extendEnv: false } : {}), }; } diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 00000000000..e659042ae39 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from "@effect/vitest"; +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 Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; + +import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; + +it.effect("launches a local OpenCode server with the project cwd and final environment", () => { + let spawnedCommand: unknown; + const spawner = ChildProcessSpawner.make((command) => { + spawnedCommand = command; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(987_654), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.make("opencode server listening on http://127.0.0.1:4310\n"), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const runtimeLayer = OpenCodeRuntimeLive.pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + return Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const sessionScope = yield* Scope.make(); + const server = yield* runtime + .startOpenCodeServerProcess({ + binaryPath: "/project/bin/opencode", + cwd: "/project/worktree", + environment: { + PATH: "/project/bin", + KEEP: "value", + OPENCODE_CONFIG_CONTENT: "direnv-must-not-win", + }, + port: 4310, + }) + .pipe(Effect.provideService(Scope.Scope, sessionScope)); + + expect(server.url).toBe("http://127.0.0.1:4310"); + const command = spawnedCommand as { + readonly options: { + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; + }; + }; + expect(command.options.cwd).toBe("/project/worktree"); + expect(command.options.extendEnv).toBe(false); + expect(command.options.env).toEqual({ + PATH: "/project/bin", + KEEP: "value", + OPENCODE_CONFIG_CONTENT: "{}", + }); + const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(closeFiber); + }).pipe(Effect.provide(runtimeLayer)); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 63fcea22d19..e21a6f2c84d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -118,6 +118,7 @@ export interface OpenCodeRuntimeShape { readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; readonly environment?: NodeJS.ProcessEnv; + readonly cwd?: string; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; @@ -131,6 +132,7 @@ export interface OpenCodeRuntimeShape { readonly binaryPath: string; readonly serverUrl?: string | null; readonly environment?: NodeJS.ProcessEnv; + readonly cwd?: string; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; @@ -457,6 +459,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(input.cwd ? { cwd: input.cwd } : {}), detached: hostPlatform !== "win32", shell: spawnCommand.shell, env: { @@ -602,6 +605,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { return startOpenCodeServerProcess({ binaryPath: input.binaryPath, ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), ...(input.port !== undefined ? { port: input.port } : {}), ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..af343643f8e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8,6 +8,7 @@ import { AuthAccessTokenType, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, + AI_USAGE_UNAVAILABLE, CommandId, DEFAULT_SERVER_SETTINGS, EnvironmentId, @@ -74,12 +75,14 @@ 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/CheckpointDiffQuery.ts"; +import * as GrokTranscriptResync from "./externalSessions/GrokTranscriptResync.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorktreeLifecycle from "./orchestration/Services/WorktreeLifecycle.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; @@ -111,6 +114,8 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; +import * as AiUsageMonitorModule from "./aiUsage/AiUsageMonitor.ts"; +import * as HostResourceProbe from "./diagnostics/HostResourceProbe.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -340,6 +345,7 @@ const buildAppUnderTest = (options?: { terminalManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; + worktreeLifecycle?: Partial; checkpointDiffQuery?: Partial; browserTraceCollector?: Partial; serverLifecycleEvents?: Partial; @@ -691,34 +697,46 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ - getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), - getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), - getShellSnapshot: () => - Effect.succeed({ - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: "1970-01-01T00:00:00.000Z", - }), - getArchivedShellSnapshot: () => - Effect.succeed({ - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: "1970-01-01T00:00:00.000Z", - }), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getProjectShellById: () => Effect.succeed(Option.none()), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - ...options?.layers?.projectionSnapshotQuery, - }), + Layer.mergeAll( + Layer.mock(WorktreeLifecycle.WorktreeLifecycle)({ + previewCleanup: () => Effect.succeed({ candidate: null }), + cleanupThreadWorktree: () => Effect.die("unused worktree cleanup"), + restoreThreadWorktree: (_input, commitUnarchive) => commitUnarchive, + ...options?.layers?.worktreeLifecycle, + }), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getProjectShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), + ...options?.layers?.projectionSnapshotQuery, + }), + ), ), Layer.provide( Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({ @@ -5118,6 +5136,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { operation: "pull", command: "git pull --ff-only", cwd: "/tmp/repo", + failureKind: "unknown", detail: "upstream missing", }); let invalidationCalls = 0; @@ -5198,6 +5217,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { operation: "commit", command: "git commit", cwd: "/tmp/repo", + failureKind: "unknown", detail: "nothing to commit", }); let invalidationCalls = 0; @@ -5675,6 +5695,57 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "fails a thread subscription as permanently deleted when the thread row is deleted", + () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadLifecycleById: () => + Effect.succeed( + Option.some({ deletedAt: "2026-01-01T00:00:01.000Z", archivedAt: null }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.runCollect), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); + assert.equal(result.failure.reason, "thread-deleted"); + assert.equal(result.failure.message, `Thread ${defaultThreadId} was deleted`); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("fails a thread subscription as retriable when no thread row exists yet", () => + Effect.gen(function* () { + yield* buildAppUnderTest({}); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.runCollect), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); + assert.equal(result.failure.reason, "thread-missing"); + assert.equal(result.failure.message, `Thread ${defaultThreadId} was not found`); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("buffers shell events published while the fallback snapshot loads", () => Effect.gen(function* () { const liveEvents = yield* PubSub.unbounded(); @@ -6928,6 +6999,304 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("checks out the base branch directly when bootstrap reuses it", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const listRefs = vi.fn((_: Parameters[0]) => + Effect.succeed({ + refs: [ + { + name: "feature/base", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "feature/base", + path: "/tmp/reuse-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + fetchRemote, + listRefs, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-reuse-branch"), + threadId: ThreadId.make("thread-bootstrap-reuse-branch"), + message: { + messageId: MessageId.make("msg-bootstrap-reuse-branch"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/base", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "feature/base", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, + reuseBaseBranch: true, + }, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 3); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.turn.start"], + ); + // Reuse wins over the requested new branch and origin refresh. + assert.equal(fetchRemote.mock.calls.length, 0); + assert.equal(listRefs.mock.calls.length, 1); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "feature/base", + path: null, + }); + const metaUpdate = dispatchedCommands[1]; + assertTrue(metaUpdate?.type === "thread.meta.update"); + if (metaUpdate?.type === "thread.meta.update") { + assert.equal(metaUpdate.branch, "feature/base"); + assert.equal(metaUpdate.worktreePath, "/tmp/reuse-worktree"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("materializes a reused remote base branch as its derived local branch", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const listRefs = vi.fn((_: Parameters[0]) => + Effect.succeed({ + refs: [ + { + name: "origin/feature/base", + isRemote: true, + remoteName: "origin", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "feature/base", + path: "/tmp/reuse-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + listRefs, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-reuse-remote-branch"), + threadId: ThreadId.make("thread-bootstrap-reuse-remote-branch"), + message: { + messageId: MessageId.make("msg-bootstrap-reuse-remote-branch"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "origin/feature/base", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "origin/feature/base", + reuseBaseBranch: true, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "origin/feature/base", + newRefName: "feature/base", + path: null, + }); + const metaUpdate = dispatchedCommands[1]; + assertTrue(metaUpdate?.type === "thread.meta.update"); + if (metaUpdate?.type === "thread.meta.update") { + assert.equal(metaUpdate.branch, "feature/base"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("resumes a replayed bootstrap after its thread was already created", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const threadId = ThreadId.make("thread-bootstrap-replay"); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.suspend(() => { + dispatchedCommands.push(command); + return command.type === "thread.create" + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: "thread.create", + detail: `Thread '${threadId}' already exists and cannot be created twice.`, + }), + ) + : Effect.succeed({ sequence: dispatchedCommands.length }); + }), + readEvents: () => Stream.empty, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + }), + ), + ), + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-replay"), + threadId, + message: { + messageId: MessageId.make("msg-bootstrap-replay"), + role: "user", + text: "hello after reconnect", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Replay", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 2); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3..396f67a3d6d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ 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 * as DirenvEnvironment from "./provider/DirenvEnvironment.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -49,6 +50,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { WorktreeLifecycleLive } from "./orchestration/Layers/WorktreeLifecycle.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -289,7 +291,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services - Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(Layer.mergeAll(WorktreeLifecycleLive, CheckpointingLayerLive)), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), @@ -309,7 +311,9 @@ 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(ProviderEventLoggers.ProviderEventLoggersLive), + Layer.provideMerge( + Layer.mergeAll(ProviderEventLoggers.ProviderEventLoggersLive, DirenvEnvironment.layerLive), + ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9ad..710dcb228c5 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -94,9 +94,12 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, @@ -157,9 +160,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -201,9 +207,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -251,9 +260,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a9759ace0b..141d1cef4c9 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -656,6 +656,7 @@ it.effect("preserves Git checkout failures without deriving the domain message f operation: "fetchRemoteBranch", command: "git fetch origin feature/source-control", cwd: "/repo", + failureKind: "unknown", detail: "remote rejected the request", }); const { layer } = makeLayer({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..a5de4117b7c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -364,6 +364,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no operation: input.operation, command: "git rev-parse --verify HEAD", cwd: input.cwd, + failureKind: "unknown", detail: "fatal: Needed a single revision", }), ) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..5174abe3bd0 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -28,6 +28,7 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; +import * as DirenvEnvironment from "../provider/DirenvEnvironment.ts"; import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -111,6 +112,7 @@ export interface GitCommitProgress { export interface GitCommitOptions { readonly timeoutMs?: number; readonly progress?: GitCommitProgress; + readonly disableSigning?: boolean; } export interface GitPushResult { @@ -873,5 +875,9 @@ export const make = Effect.gen(function* () { 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).pipe( + Layer.provide(DirenvEnvironment.layerLive), +); +export const layer = Layer.effect(GitVcsDriver, make).pipe( + Layer.provide(DirenvEnvironment.layerLive), +); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7b5956de07f..3f272050d6c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -12,7 +12,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + DirenvEnvironment, + identityDirenvEnvironmentResolver, +} from "../provider/DirenvEnvironment.ts"; +import { + makeGitVcsDriverCore, + redactGitOutput, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -88,6 +96,7 @@ const initRepoWithCommit = ( yield* driver.initRepo({ cwd }); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); + yield* git(cwd, ["config", "commit.gpgSign", "false"]); yield* writeTextFile(cwd, "README.md", "# test\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "initial commit"]); @@ -671,8 +680,19 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "feature-worktree", ); const driver = yield* GitVcsDriver.GitVcsDriver; + const approvedWorktrees: Array = []; + const driverWithApprovalSpy = yield* makeGitVcsDriverCore().pipe( + Effect.provide(ServerConfigLayer), + Effect.provideService(DirenvEnvironment, { + allow: ({ cwd }) => + Effect.sync(() => { + approvedWorktrees.push(cwd); + }), + resolve: identityDirenvEnvironmentResolver, + }), + ); - const created = yield* driver.createWorktree({ + const created = yield* driverWithApprovalSpy.createWorktree({ cwd, path: worktreePath, refName: initialBranch, @@ -682,6 +702,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.worktree.path, worktreePath); assert.equal(created.worktree.refName, "feature/worktree"); assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); + assert.deepStrictEqual(approvedWorktrees, [worktreePath]); yield* driver.removeWorktree({ cwd, path: worktreePath }); const fileSystem = yield* FileSystem.FileSystem; @@ -786,6 +807,76 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.include(status, "?? selected1.txt"); }), ); + + it("recognizes representative GPG, pinentry, and SSH signing diagnostics", () => { + assert.isTrue(isCommitSigningFailureStderr("error: gpg failed to sign the data")); + assert.isTrue( + isCommitSigningFailureStderr( + "gpg: signing failed: Inappropriate ioctl for device\nfatal: failed to write commit object", + ), + ); + assert.isTrue(isCommitSigningFailureStderr("error: ssh-keygen failed to sign the data")); + assert.isTrue( + isCommitSigningFailureStderr( + "error: Couldn't load public key /tmp/missing.pub: No such file or directory", + ), + ); + assert.isFalse(isCommitSigningFailureStderr("fatal: failed to write commit object")); + }); + + it.effect("classifies signing failures and can commit unsigned for one attempt", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const signerPath = pathService.join(cwd, "failing-signer.sh"); + yield* fileSystem.writeFileString( + signerPath, + "#!/bin/sh\necho 'gpg: signing failed: No secret key' >&2\nexit 1\n", + ); + yield* fileSystem.chmod(signerPath, 0o755); + yield* git(cwd, ["config", "commit.gpgSign", "true"]); + yield* git(cwd, ["config", "gpg.format", "openpgp"]); + yield* git(cwd, ["config", "gpg.program", signerPath]); + yield* writeTextFile(cwd, "signed.txt", "sign me\n"); + yield* git(cwd, ["add", "signed.txt"]); + + const error = yield* driver.commit(cwd, "Signed commit", "").pipe(Effect.flip); + assert.equal(error.failureKind, "commit_signing_failed"); + assert.notProperty(error, "stderr"); + + const commit = yield* driver.commit(cwd, "Unsigned commit", "", { + disableSigning: true, + }); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Unsigned commit"); + assert.notInclude(yield* git(cwd, ["cat-file", "commit", "HEAD"]), "gpgsig "); + assert.equal(yield* git(cwd, ["config", "--bool", "commit.gpgSign"]), "true"); + }), + ); + + it.effect("does not classify a failed commit hook as a signing failure", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const hookPath = pathService.join(cwd, ".git", "hooks", "pre-commit"); + yield* fileSystem.writeFileString( + hookPath, + "#!/bin/sh\necho 'error: gpg failed to sign the data' >&2\nexit 1\n", + ); + yield* fileSystem.chmod(hookPath, 0o755); + yield* writeTextFile(cwd, "hooked.txt", "fail first\n"); + yield* git(cwd, ["add", "hooked.txt"]); + + const error = yield* driver.commit(cwd, "Hook failure", "").pipe(Effect.flip); + assert.equal(error.failureKind, "unknown"); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33eb6d40d76..7ff393018b3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -28,6 +28,7 @@ import { import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { DirenvEnvironment } from "../provider/DirenvEnvironment.ts"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -61,6 +62,29 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; + +const COMMIT_SIGNING_FAILURE_PATTERNS = [ + /gpg(?:2)?(?:\.exe)?: .*failed to sign/i, + /gpg failed to sign the data/i, + /signing failed:/i, + /failed to sign the data/i, + /pinentry.*(?:failed|error|not found|no such file|cancell?ed)/i, + /(?:failed|error|no such file|cancell?ed).*pinentry/i, + /inappropriate ioctl for device/i, + /cannot open \/dev\/tty/i, + /no secret key/i, + /secret key not available/i, + /ssh-keygen(?:\.exe)?:?.*(?:failed|error|couldn['’]t).*sign/i, + /couldn['’]t sign (?:message|data)/i, + /couldn['’]t load public key/i, + /no private key found for public key/i, + /load key .*: (?:invalid format|no such file or directory|permission denied)/i, + /agent refused operation/i, +] as const; + +export function isCommitSigningFailureStderr(stderr: string): boolean { + return COMMIT_SIGNING_FAILURE_PATTERNS.some((pattern) => pattern.test(stderr)); +} const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, @@ -325,6 +349,7 @@ function gitCommandContext( command: "git", cwd: input.cwd, argumentCount: input.args.length, + failureKind: "unknown" as const, } as const; } @@ -437,16 +462,15 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } - if (traceRecord.success.child_class !== "hook") { - return; - } - const event = traceRecord.success.event; const childKey = trace2ChildKey(traceRecord.success); if (childKey === null) { return; } const started = hookStartByChildKey.get(childKey); + if (traceRecord.success.child_class !== "hook" && started === undefined) { + return; + } const hookNameFromEvent = typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); @@ -468,7 +492,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_exit") { hookStartByChildKey.delete(childKey); - const code = traceRecord.success.exitCode; + const code = traceRecord.success.code ?? traceRecord.success.exitCode; const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; const now = yield* DateTime.now; const durationMs = started @@ -640,6 +664,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + const direnvEnvironment = yield* DirenvEnvironment; + + const approveWorktreeEnvironment = (cwd: string) => + direnvEnvironment.allow({ cwd, environment: process.env }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to approve direnv for a newly created worktree.", { + cwd, + detail: error.message, + }), + ), + ); const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { @@ -686,6 +721,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + const onStdoutLine = input.progress?.onStdoutLine; + const onStderrLine = input.progress?.onStderrLine; const [stdout, stderr, exitCode] = yield* Effect.all( [ collectOutput( @@ -693,14 +730,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* child.stdout, maxOutputBytes, appendTruncationMarker, - input.progress?.onStdoutLine, + onStdoutLine + ? (line) => trace2Monitor.flush.pipe(Effect.andThen(onStdoutLine(line))) + : undefined, ), collectOutput( commandInput, child.stderr, maxOutputBytes, appendTruncationMarker, - input.progress?.onStderrLine, + onStderrLine + ? (line) => trace2Monitor.flush.pipe(Effect.andThen(onStderrLine(line))) + : undefined, ), child.exitCode.pipe( Effect.mapError( @@ -1571,25 +1612,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - const args = ["commit", "-m", subject]; + const args = ["commit"]; + if (options?.disableSigning) { + args.push("--no-gpg-sign"); + } + args.push("-m", subject); const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } - const progress = - options?.progress?.onOutputLine === undefined - ? options?.progress - : { - ...options.progress, + let hookFailed = false; + const progress: GitVcsDriver.ExecuteGitProgress = { + ...(options?.progress?.onOutputLine + ? { onStdoutLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, - }; - yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + } + : {}), + ...(options?.progress?.onHookStarted + ? { onHookStarted: options.progress.onHookStarted } + : {}), + onHookFinished: (input) => { + if (input.exitCode !== null && input.exitCode !== 0) { + hookFailed = true; + } + return options?.progress?.onHookFinished?.(input) ?? Effect.void; + }, + }; + const result = yield* executeGitWithStableDiagnostics("GitVcsDriver.commit.commit", cwd, args, { + allowNonZeroExit: true, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(progress ? { progress } : {}), - }).pipe(Effect.asVoid); + progress, + }); + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.commit.commit", cwd, args }), + detail: "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + ...(!options?.disableSigning && !hookFailed && isCommitSigningFailureStderr(result.stderr) + ? { failureKind: "commit_signing_failed" as const } + : {}), + }); + } const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD", @@ -1941,6 +2009,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* operation: "GitVcsDriver.getReviewDiffPreview.hash", command: "crypto.digest SHA-256", cwd: input.cwd, + failureKind: "unknown", detail: "Failed to hash review diff.", cause, }), @@ -2276,6 +2345,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fallbackErrorDetail: "git worktree add failed", }); + yield* approveWorktreeEnvironment(worktreePath); + if (input.newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( @@ -2399,7 +2470,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } args.push(input.path); yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, + timeoutMs: 30_000, fallbackErrorDetail: "git worktree remove failed", }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..c30a351a609 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -71,6 +71,7 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorktreeLifecycle from "./orchestration/Services/WorktreeLifecycle.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, @@ -116,6 +117,12 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { + isValidOmegentT3ProductHandshake, + OMEGENT_T3_CLIENT_REQUIRED_MESSAGE, + parseProductHandshakeFromSearchParams, +} from "@t3tools/shared/productFamily"; +import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -328,6 +335,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPreviewWorktreeCleanup, AuthOrchestrationReadScope], + [WS_METHODS.vcsCleanupThreadWorktree, AuthOrchestrationOperateScope], [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], @@ -415,6 +424,7 @@ const makeWsRpcLayer = ( const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const worktreeLifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; @@ -996,24 +1006,45 @@ const makeWsRpcLayer = ( } if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { + const prepareWorktree = bootstrap.prepareWorktree; + let worktreeBaseRef = prepareWorktree.baseBranch; + let worktreeNewRefName = prepareWorktree.branch; + let worktreeBaseRefName: string | undefined = prepareWorktree.baseBranch; + if (prepareWorktree.reuseBaseBranch) { + // Reuse the selected branch: check it out in the worktree + // instead of branching off it. A remote ref cannot be checked + // out directly (it would detach), so materialize it as its + // derived local branch; the branch keeps its own history, so + // skip the gh-merge-base config that new branches record. + const refsResult = yield* gitWorkflow.listRefs({ + cwd: prepareWorktree.projectCwd, + query: prepareWorktree.baseBranch, + includeMatchingRemoteRefs: true, + }); + const selectedRef = refsResult.refs.find( + (ref) => ref.name === prepareWorktree.baseBranch, + ); + worktreeNewRefName = selectedRef?.isRemote + ? deriveLocalBranchNameFromRemoteRef(prepareWorktree.baseBranch) + : undefined; + worktreeBaseRefName = undefined; + } else if (prepareWorktree.startFromOrigin) { yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, remoteName: "origin", }); const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, fallbackRemoteName: "origin", }); worktreeBaseRef = resolvedRemoteBase.commitSha; } const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + ...(worktreeNewRefName !== undefined ? { newRefName: worktreeNewRefName } : {}), + ...(worktreeBaseRefName !== undefined ? { baseRefName: worktreeBaseRefName } : {}), path: null, }); targetWorktreePath = worktree.worktree.path; @@ -1126,7 +1157,26 @@ const makeWsRpcLayer = ( Effect.orElseSucceed(() => false), ) : false; - const result = yield* dispatchNormalizedCommand(normalizedCommand); + // Unarchive restores a missing worktree from the retained + // branch before the command commits; a failed restoration + // leaves the thread archived instead of silently detaching it + // to the main project checkout. + const result = + normalizedCommand.type === "thread.unarchive" + ? yield* worktreeLifecycle + .restoreThreadWorktree( + { threadId: normalizedCommand.threadId }, + dispatchNormalizedCommand(normalizedCommand), + ) + .pipe( + Effect.mapError((error) => + toDispatchCommandError( + error, + "Failed to restore the thread's worktree before unarchive.", + ), + ), + ) + : yield* dispatchNormalizedCommand(normalizedCommand); if (normalizedCommand.type === "thread.archive") { if (shouldStopSessionAfterArchive) { yield* Effect.gen(function* () { @@ -1429,9 +1479,29 @@ const makeWsRpcLayer = ( ); if (Option.isNone(snapshot)) { + // Distinguish permanently unavailable threads from a row that + // may not be projected yet, so clients can stop resubscribing + // to deleted/archived threads instead of retrying forever. + const lifecycle = yield* projectionSnapshotQuery + .getThreadLifecycleById(input.threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + const reason = Option.match(lifecycle, { + onNone: () => "thread-missing" as const, + onSome: (row) => + row.deletedAt !== null + ? ("thread-deleted" as const) + : row.archivedAt !== null + ? ("thread-archived" as const) + : ("thread-missing" as const), + }); return yield* new OrchestrationGetSnapshotError({ - message: `Thread ${input.threadId} was not found`, - cause: input.threadId, + message: + reason === "thread-deleted" + ? `Thread ${input.threadId} was deleted` + : reason === "thread-archived" + ? `Thread ${input.threadId} is archived` + : `Thread ${input.threadId} was not found`, + reason, }); } @@ -1828,6 +1898,18 @@ const makeWsRpcLayer = ( gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsPreviewWorktreeCleanup]: (input) => + observeRpcEffect( + WS_METHODS.vcsPreviewWorktreeCleanup, + worktreeLifecycle.previewCleanup(input), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsCleanupThreadWorktree]: (input) => + observeRpcEffect( + WS_METHODS.vcsCleanupThreadWorktree, + worktreeLifecycle.cleanupThreadWorktree(input), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( WS_METHODS.vcsCreateRef, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e703427d4b6..6ac68d8cf0a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -51,6 +51,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + reuseBaseBranch: boolean; + onReuseBaseBranchChange: (reuseBaseBranch: boolean) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -125,7 +127,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); @@ -135,7 +137,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ } - className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" + className="min-w-0 max-w-[48%] justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" > {triggerContent} @@ -224,6 +226,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + reuseBaseBranch, + onReuseBaseBranchChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -355,6 +359,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + reuseBaseBranch={reuseBaseBranch} + onReuseBaseBranchChange={onReuseBaseBranchChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 763de56d8c4..79a068b9629 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -72,6 +72,8 @@ interface BranchToolbarBranchSelectorProps { onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + reuseBaseBranch: boolean; + onReuseBaseBranchChange: (reuseBaseBranch: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -84,12 +86,15 @@ function getBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: "local" | "worktree"; resolvedActiveBranch: string | null; + reuseBaseBranch: boolean; }): string { - const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; + const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, reuseBaseBranch } = input; if (!resolvedActiveBranch) { return "Select ref"; } - if (effectiveEnvMode === "worktree" && !activeWorktreePath) { + // "From X" signals a new branch will be created off X; a reused branch is + // checked out as-is, so show its plain name. + if (effectiveEnvMode === "worktree" && !activeWorktreePath && !reuseBaseBranch) { return `From ${resolvedActiveBranch}`; } return resolvedActiveBranch; @@ -106,10 +111,13 @@ export function BranchToolbarBranchSelector({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + reuseBaseBranch, + onReuseBaseBranchChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const startFromOriginSwitchId = useId(); + const reuseBaseBranchSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -587,6 +595,7 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, + reuseBaseBranch, }); // PR pill shown next to the branch selector when the active branch has one. @@ -722,12 +731,10 @@ export function BranchToolbarBranchSelector({ {branchPrTooltip} ) : null} - {/* Context menu lives on the wrapper: the disabled Button has - pointer-events-none, so the trigger itself never sees right-clicks - while refs are loading or a branch action is pending. */} - handleBranchContextMenu(event, resolvedActiveBranch)} + } + className="min-w-0 shrink text-muted-foreground/70 hover:text-foreground/80" + disabled={isInitialBranchesLoadPending || isBranchActionPending} > } @@ -800,32 +807,65 @@ export function BranchToolbarBranchSelector({ {isSelectingWorktreeBase ? ( - - - - - onStartFromOriginChange(Boolean(checked))} - /> - - } - /> - - Creates the worktree from the latest matching branch on origin instead of your local - branch. - - +
+ + + + + onReuseBaseBranchChange(Boolean(checked))} + /> + + } + /> + + Checks out the selected branch in the worktree instead of creating a new branch + from it. + + + + + + + onStartFromOriginChange(Boolean(checked))} + /> + + } + /> + + {reuseBaseBranch + ? "Not available when reusing the selected branch." + : "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 ab1256cddb3..d2bcee925bf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,6 +112,7 @@ import { type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; @@ -1189,6 +1190,7 @@ function ChatViewContent(props: ChatViewProps) { const timestampFormat = settings.timestampFormat; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); + const handleNewThread = useNewThreadHandler(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -1285,6 +1287,13 @@ function ChatViewContent(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); + const [pendingWorktreeThreadIds, setPendingWorktreeThreadIds] = useState>( + () => new Set(), + ); + const [ + pendingServerThreadReuseBaseBranchByThreadId, + setPendingServerThreadReuseBaseBranchByThreadId, + ] = useState>({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -3818,6 +3827,29 @@ function ChatViewContent(props: ChatViewProps) { ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? primaryServerSettings.newWorktreesStartFromOrigin) : false; + const reuseBaseBranch = isLocalDraftThread + ? (draftThread?.reuseBaseBranch ?? false) + : canOverrideServerThreadEnvMode + ? (pendingServerThreadReuseBaseBranchByThreadId[activeThread?.id ?? ""] ?? false) + : false; + const handleStartNewThread = useCallback(() => { + if (!activeProjectRef) return; + void handleNewThread(activeProjectRef, { + branch: activeThreadBranch, + worktreePath: activeWorktreePath, + envMode, + reuseBaseBranch, + startFromOrigin, + }); + }, [ + activeProjectRef, + activeThreadBranch, + activeWorktreePath, + envMode, + handleNewThread, + reuseBaseBranch, + startFromOrigin, + ]); const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, @@ -4500,10 +4532,14 @@ function ChatViewContent(props: ChatViewProps) { ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { - handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); + if (standaloneSlashCommand === "new") { + handleStartNewThread(); + } else { + handleInteractionModeChange(standaloneSlashCommand); + } return; } if (!hasSendableContent) { @@ -4711,47 +4747,43 @@ function ChatViewContent(props: ChatViewProps) { } } - const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise); - if (failure === null && turnAttachmentsResult._tag === "Failure") { - failure = turnAttachmentsResult; - } - - let turnStartSucceeded = false; - if (failure === null && turnAttachmentsResult._tag === "Success") { - const bootstrap = - isLocalDraftThread || baseBranchForWorktree - ? { - ...(isLocalDraftThread - ? { - createThread: { - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, - createdAt: activeThread.createdAt, - }, - } - : {}), - ...(baseBranchForWorktree - ? { - prepareWorktree: { - projectCwd: activeProject.workspaceRoot, - baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), - ...(startFromOrigin ? { startFromOrigin: true } : {}), - }, - runSetupScript: true, - } - : {}), - } - : undefined; - beginLocalDispatch({ preparingWorktree: false }); - const startResult = await startThreadTurn({ - environmentId, - input: { + if (failure === null && turnAttachmentsResult._tag === "Success") { + const bootstrap = + isLocalDraftThread || baseBranchForWorktree + ? { + ...(isLocalDraftThread + ? { + createThread: { + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + } + : {}), + ...(baseBranchForWorktree + ? { + prepareWorktree: { + projectCwd: activeProject.workspaceRoot, + baseBranch: baseBranchForWorktree, + ...(reuseBaseBranch + ? { reuseBaseBranch: true } + : { + branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), + }), + }, + runSetupScript: true, + } + : {}), + } + : undefined; + const queuedTurnInput = { + commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, @@ -5443,7 +5475,10 @@ function ChatViewContent(props: ChatViewProps) { envMode: mode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), - ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + reuseBaseBranch: false, + ...(target !== "current-worktree" && draftThread?.worktreePath + ? { worktreePath: null } + : {}), }); } scheduleComposerFocus(); @@ -5476,6 +5511,22 @@ function ChatViewContent(props: ChatViewProps) { } }; + const onReuseBaseBranchChange = (nextReuseBaseBranch: boolean) => { + if (canOverrideServerThreadEnvMode && activeThread) { + setPendingServerThreadReuseBaseBranchByThreadId((current) => + current[activeThread.id] === nextReuseBaseBranch + ? current + : { ...current, [activeThread.id]: nextReuseBaseBranch }, + ); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + reuseBaseBranch: nextReuseBaseBranch, + }); + } + }; + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -5774,6 +5825,89 @@ function ChatViewContent(props: ChatViewProps) { : undefined } > +
+ +
, + shortcutCommand: "board.open", + run: async () => { + await navigate({ to: "/board" }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 11716e935ae..414f93f19c5 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -4,6 +4,10 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { + buildUnsignedCommitRetryInput, + isCommitSigningFailure, +} from "@t3tools/client-runtime/state/vcs"; import type { GitActionProgressEvent, GitRunStackedActionResult, @@ -103,7 +107,7 @@ interface PendingDefaultBranchAction { includesCommit: boolean; commitMessage?: string; onConfirmed?: () => void; - filePaths?: string[]; + filePaths?: ReadonlyArray; } type PublishProviderKind = Extract< @@ -132,8 +136,9 @@ interface RunGitActionWithToastInput { skipDefaultBranchPrompt?: boolean; statusOverride?: VcsStatusResult | null; featureBranch?: boolean; + disableCommitSigning?: boolean; progressToastId?: GitActionToastId; - filePaths?: string[]; + filePaths?: ReadonlyArray; } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; @@ -1019,6 +1024,7 @@ export default function GitActionsControl({ title: progress.title, description: resolveProgressDescription(progress), timeout: 0, + actionProps: undefined, data: progress.toastData, }); }, []); @@ -1250,6 +1256,7 @@ export default function GitActionsControl({ skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, + disableCommitSigning = false, progressToastId, filePaths, }: RunGitActionWithToastInput) => { @@ -1326,6 +1333,7 @@ export default function GitActionsControl({ title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...", timeout: 0, + actionProps: undefined, data: scopedToastData, }); } @@ -1391,6 +1399,7 @@ export default function GitActionsControl({ action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(disableCommitSigning ? { disableCommitSigning: true } : {}), ...(filePaths ? { filePaths } : {}), onProgress: applyProgressEvent, }); @@ -1403,6 +1412,42 @@ export default function GitActionsControl({ } const error = squashAtomCommandFailure(result); + if (!disableCommitSigning && isCommitSigningFailure(error)) { + const retryInput = buildUnsignedCommitRetryInput({ + action, + ...(commitMessage ? { commitMessage } : {}), + ...(featureBranch ? { featureBranch: true } : {}), + ...(filePaths ? { filePaths } : {}), + }); + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "error", + title: "Commit signing failed", + description: "Git couldn’t sign the commit. Retry this commit without signing?", + timeout: 0, + actionProps: { + children: "Retry without signing", + onClick: () => { + void runGitActionWithToast({ + action: retryInput.action, + ...(retryInput.commitMessage !== undefined + ? { commitMessage: retryInput.commitMessage } + : {}), + ...(retryInput.filePaths !== undefined + ? { filePaths: retryInput.filePaths } + : {}), + disableCommitSigning: true, + skipDefaultBranchPrompt: true, + progressToastId: resolvedProgressToastId, + }); + }, + }, + ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), + }), + ); + return; + } toastManager.update( resolvedProgressToastId, stackedThreadToast({ diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc3e8ee832f..fc09bbbaa12 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -33,14 +33,8 @@ export function ProjectFavicon(input: { ); } -function ProjectFaviconFallback({ - className, - icon: Icon, -}: { - readonly className?: string | undefined; - readonly icon: ComponentType<{ className?: string }>; -}) { - return ; +export function ProjectFaviconFallback({ className }: { readonly className?: string | undefined }) { + return ; } function ProjectFaviconImage({ diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..7a635214558 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + buildSidebarV2ThreadContextMenuItems, + buildThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -163,6 +165,90 @@ describe("buildMultiSelectThreadContextMenuItems", () => { }); }); +describe("buildSidebarV2ThreadContextMenuItems", () => { + const baseInput = { + branch: null, + supportsSettlement: false, + isSettled: false, + supportsSnooze: false, + isSnoozed: false, + canSnoozeNow: true, + snoozePresets: [], + } as const; + + it("offers settlement actions matching the thread's state and server support", () => { + expect( + buildSidebarV2ThreadContextMenuItems({ + ...baseInput, + supportsSettlement: true, + })[0], + ).toEqual({ id: "settle", label: "Settle thread" }); + expect( + buildSidebarV2ThreadContextMenuItems({ + ...baseInput, + supportsSettlement: true, + isSettled: true, + })[0], + ).toEqual({ id: "unsettle", label: "Un-settle thread" }); + expect(buildSidebarV2ThreadContextMenuItems(baseInput)).toEqual([ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ]); + }); + + it("leads with a new-thread item for the thread's branch", () => { + expect( + buildSidebarV2ThreadContextMenuItems({ + ...baseInput, + branch: "feature/login", + supportsSettlement: true, + })[0], + ).toEqual({ id: "new-thread-on-branch", label: "New thread on feature/login" }); + }); + + it("offers snooze presets when supported, disabled while the thread blocks on the user", () => { + const presets = [ + { + id: "hour", + label: "In 1 hour", + whenLabel: "10:00 AM", + snoozedUntil: "2026-07-24T10:00:00.000Z", + }, + ] as const; + expect( + buildSidebarV2ThreadContextMenuItems({ + ...baseInput, + supportsSnooze: true, + snoozePresets: presets, + })[0], + ).toEqual({ + id: "snooze", + label: "Snooze", + disabled: false, + children: [{ id: "snooze:hour", label: "In 1 hour (10:00 AM)" }], + }); + expect( + buildSidebarV2ThreadContextMenuItems({ + ...baseInput, + supportsSnooze: true, + canSnoozeNow: false, + snoozePresets: presets, + })[0], + ).toMatchObject({ id: "snooze", disabled: true }); + }); + + it("offers wake instead of snooze for a snoozed thread", () => { + expect( + buildSidebarV2ThreadContextMenuItems({ + ...baseInput, + supportsSnooze: true, + isSnoozed: true, + })[0], + ).toEqual({ id: "unsnooze", label: "Wake thread" }); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7aee3100d0e..5a540af40e4 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,8 @@ import * as React from "react"; +import { + effectiveSettled, + type ChangeRequestStateLike, +} from "@t3tools/client-runtime/state/thread-settled"; import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { @@ -12,12 +16,36 @@ import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; +import type { SnoozePreset } from "./Sidebar.snooze"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // 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; +// Settled-tail paging: recent history is the common lookup; the deep tail +// stays behind an explicit Show more. Shared by SidebarV2 and the board. +export const SETTLED_TAIL_INITIAL_COUNT = 10; +export const SETTLED_TAIL_PAGE_COUNT = 25; +export type SidebarNewThreadEnvMode = "local" | "worktree"; +export type SidebarThreadWorktreeSection = + | { + kind: "thread"; + thread: SidebarThreadSummary; + /** Resolved checkout path for PR/git status when this thread is not grouped. */ + checkoutPath?: string; + } + | { + kind: "worktree"; + key: string; + label: string; + branch: string | null; + checkoutPath: string; + source: "local" | "worktree"; + worktreePath: string | null; + threads: SidebarThreadSummary[]; + }; + type SidebarProject = { id: string; title: string; @@ -94,6 +122,82 @@ export function buildMultiSelectThreadContextMenuItems(input: { ]; } +export type ThreadContextMenuAction = + | "rename" + | "mark-unread" + | "copy-path" + | "copy-thread-id" + | "delete"; + +export function buildThreadContextMenuItems(): readonly ContextMenuItem[] { + return [ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy Path" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ]; +} + +export type SidebarV2ThreadContextMenuAction = + | "new-thread-on-branch" + | "settle" + | "unsettle" + | "snooze" + | `snooze:${string}` + | "unsnooze" + | "rename" + | "mark-unread" + | "delete"; + +// Single source for the per-thread menu on both v2 surfaces (sidebar rows +// and board cards) so the two can't drift apart item-by-item. +export function buildSidebarV2ThreadContextMenuItems(input: { + branch: string | null; + supportsSettlement: boolean; + isSettled: boolean; + supportsSnooze: boolean; + isSnoozed: boolean; + canSnoozeNow: boolean; + snoozePresets: ReadonlyArray; +}): readonly ContextMenuItem[] { + return [ + ...(input.branch + ? [ + { + id: "new-thread-on-branch", + label: `New thread on ${input.branch}`, + } as const, + ] + : []), + ...(input.supportsSettlement + ? [ + input.isSettled + ? ({ id: "unsettle", label: "Un-settle thread" } as const) + : ({ id: "settle", label: "Settle thread" } as const), + ] + : []), + ...(input.supportsSnooze + ? [ + input.isSnoozed + ? ({ id: "unsnooze", label: "Wake thread" } as const) + : ({ + id: "snooze", + label: "Snooze", + disabled: !input.canSnoozeNow, + children: input.snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}` as const, + label: `${preset.label} (${preset.whenLabel})`, + })), + } satisfies ContextMenuItem), + ] + : []), + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ]; +} + export interface ThreadStatusPill { label: | "Working" @@ -220,15 +324,63 @@ export function useThreadJumpHintVisibility(): { }; } +/** + * Whether `completedAt` is a completion the user has not seen yet: a missing + * last-visited timestamp counts as seen, an unparsable one as unseen. + */ +export function isCompletionUnseen( + completedAt: string | null | undefined, + lastVisitedAt: string | null | undefined, +): boolean { + if (!completedAt) return false; + const completedAtMs = Date.parse(completedAt); + if (Number.isNaN(completedAtMs)) return false; + if (!lastVisitedAt) return false; + + const lastVisitedAtMs = Date.parse(lastVisitedAt); + if (Number.isNaN(lastVisitedAtMs)) return true; + return completedAtMs > lastVisitedAtMs; +} + 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 false; + return isCompletionUnseen(thread.latestTurn?.completedAt, thread.lastVisitedAt); +} - const lastVisitedAt = Date.parse(thread.lastVisitedAt); - if (Number.isNaN(lastVisitedAt)) return true; - return completedAt > lastVisitedAt; +/** + * Shared settled classification for display surfaces (sidebar v2, board), so + * they always agree on what is settled. Threads on servers without the + * settlement capability (old server, or descriptor not loaded yet) never + * classify as settled: the user could neither un-settle nor pin them, so + * auto-settling them would strand rows in a tail with no working affordances. + */ +export function isThreadSettledForDisplay( + thread: Parameters[0] & { readonly environmentId: string }, + input: { + serverConfigs: { + get(environmentId: string): + | { + readonly environment: { + readonly capabilities: { readonly threadSettlement?: boolean }; + }; + } + | undefined; + }; + now: string; + autoSettleAfterDays: number | null; + changeRequestState: ChangeRequestStateLike | null; + }, +): boolean { + const supportsSettlement = + input.serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + return ( + supportsSettlement && + effectiveSettled(thread, { + now: input.now, + autoSettleAfterDays: input.autoSettleAfterDays, + changeRequestState: input.changeRequestState, + }) + ); } export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { @@ -424,6 +576,53 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S return "ready"; } +export interface SidebarV2TopStatus { + label: "Working" | "Approval" | "Input" | "Failed" | "Done"; + icon: "working" | "done" | null; + className: string; +} + +export function resolveSidebarV2TopStatus(input: { + status: SidebarV2Status; + isUnread: boolean; +}): SidebarV2TopStatus | null { + switch (input.status) { + case "working": + return { + label: "Working", + icon: "working", + className: + "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", + }; + case "approval": + return { + label: "Approval", + icon: null, + className: "text-amber-700 dark:text-amber-300", + }; + case "input": + return { + label: "Input", + icon: null, + className: "text-indigo-600 dark:text-indigo-300", + }; + case "failed": + return { + label: "Failed", + icon: null, + className: "text-red-700 dark:text-red-300", + }; + case "ready": + return input.isUnread + ? { + label: "Done", + icon: "done", + className: "text-emerald-700 dark:text-emerald-300", + } + : null; + } +} + /** NaN-safe Date.parse for sort comparators: a malformed timestamp must not poison the whole ordering, so it sinks to the epoch instead. */ export function parseTimestampMs(isoDate: string): number { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..f00c4c5d88b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,6 +8,8 @@ import { Globe2Icon, LoaderIcon, SearchIcon, + SettingsIcon, + SquareKanbanIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, @@ -2488,6 +2490,42 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); +// Self-contained so its router hooks don't force new props through the +// memoized sidebar content on every navigation. +function SidebarBoardLink({ shortcutLabel }: { shortcutLabel: string | null }) { + const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const { isMobile, setOpenMobile } = useSidebar(); + const isActive = pathname === "/board"; + + return ( + { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/board" }); + }} + > + + Board + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + ); +} + function LocalSecondaryStatus() { const { environments } = useEnvironments(); // The desktop reports which local secondary backends (e.g. the WSL backend) @@ -2752,6 +2790,7 @@ interface SidebarProjectsContentProps { routeThreadKey: string | null; newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; + boardShortcutLabel: string | null; threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; @@ -2792,6 +2831,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( routeThreadKey, newThreadShortcutLabel, commandPaletteShortcutLabel, + boardShortcutLabel, threadJumpLabelByKey, attachThreadListAutoAnimateRef, expandThreadListForProject, @@ -2845,6 +2885,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ) : null} + + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -3413,6 +3456,16 @@ export default function Sidebar() { platform, context: shortcutContext, }); + if (command === "board.open") { + event.preventDefault(); + event.stopPropagation(); + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/board" }); + return; + } + const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { const targetThreadKey = resolveAdjacentThreadId({ @@ -3460,12 +3513,15 @@ export default function Sidebar() { }; }, [ getCurrentSidebarShortcutContext, + isMobile, keybindings, + navigate, navigateToThread, orderedSidebarThreadKeys, platform, routeThreadKey, sidebarThreadByKey, + setOpenMobile, threadJumpThreadKeys, ]); @@ -3498,6 +3554,11 @@ export default function Sidebar() { "commandPalette.toggle", newThreadShortcutLabelOptions, ); + const boardShortcutLabel = shortcutLabelForCommand( + keybindings, + "board.open", + newThreadShortcutLabelOptions, + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -3623,6 +3684,7 @@ export default function Sidebar() { routeThreadKey={routeThreadKey} newThreadShortcutLabel={newThreadShortcutLabel} commandPaletteShortcutLabel={commandPaletteShortcutLabel} + boardShortcutLabel={boardShortcutLabel} threadJumpLabelByKey={visibleThreadJumpLabelByKey} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..b8ed3404f95 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -101,6 +101,9 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + SETTLED_TAIL_INITIAL_COUNT, + SETTLED_TAIL_PAGE_COUNT, + buildSidebarV2ThreadContextMenuItems, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -154,10 +157,6 @@ import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; -// Settled-tail paging: recent history is the common lookup; the deep tail -// stays behind an explicit Show more. -const SETTLED_TAIL_INITIAL_COUNT = 10; -const SETTLED_TAIL_PAGE_COUNT = 25; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -1984,41 +1983,15 @@ export default function SidebarV2() { const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( - [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - label: `New thread on ${thread.branch}`, - }, - ] - : []), - ...(supportsSettlement - ? [ - isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, - ] - : []), - ...(supportsSnooze - ? [ - isSnoozed - ? { id: "unsnooze", label: "Wake thread" } - : { - id: "snooze", - label: "Snooze", - disabled: !canSnooze(thread, { now: new Date().toISOString() }), - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), - }, - ] - : []), - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, - ], + buildSidebarV2ThreadContextMenuItems({ + branch: thread.branch, + supportsSettlement, + isSettled, + supportsSnooze, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + snoozePresets, + }), position, ), ); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0d3291a9e28..e830ed1078b 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -3,17 +3,38 @@ import { scopedThreadKey, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import type { VcsStatusResult } from "@t3tools/contracts"; -import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import type { EnvironmentId, VcsStatusResult } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { + CircleCheckIcon, + CircleDashedIcon, + CloudIcon, + FolderGit2Icon, + FolderPlusIcon, + GitPullRequestIcon, + PencilRulerIcon, + TerminalIcon, +} from "lucide-react"; import { useMemo } from "react"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { cn } from "../lib/utils"; import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; -import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; -import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { + resolveChangeRequestPresentation, + resolveThreadChangeRequest, +} from "@t3tools/shared/sourceControl"; +import { + resolveThreadStatusPill, + type SidebarV2TopStatus, + type ThreadStatusPill, +} from "./Sidebar.logic"; import type { SidebarThreadSummary } from "../types"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -178,6 +199,80 @@ export function ThreadWorktreeIndicator({ ); } +export function ThreadPlanModeIndicator({ + thread, +}: { + thread: Pick; +}) { + if (thread.interactionMode !== "plan") { + return null; + } + + return ( + + + } + > + + + Plan-only thread + + ); +} + +export function ThreadSettledIndicator({ thread }: { thread: Pick }) { + return ( + + + } + > + + Settled + + Settled + + ); +} + +export function ThreadStatusV2Indicator({ + status, + className, +}: { + status: SidebarV2TopStatus; + className?: string; +}) { + return ( + + {status.icon === "working" ? ( + + ) : status.icon === "done" ? ( + + ) : null} + {status.label} + + ); +} + export function ThreadStatusLabel({ status, compact = false, diff --git a/apps/web/src/components/board/Board.logic.test.ts b/apps/web/src/components/board/Board.logic.test.ts new file mode 100644 index 00000000000..b6b5445fd23 --- /dev/null +++ b/apps/web/src/components/board/Board.logic.test.ts @@ -0,0 +1,607 @@ +import { EnvironmentId, ProjectId, type VcsStatusResult } from "@t3tools/contracts"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { describe, expect, it } from "vite-plus/test"; +import { + BOARD_ARCHIVE_DROPPABLE_ID, + BOARD_SETTLED_COLUMN_DROPPABLE_ID, + BOARD_TRASH_DROPPABLE_ID, + BOARD_UNSETTLE_DROPPABLE_ID, + boardWorktreeGroupDragId, + boardWorktreeKey, + buildBoardColumns, + buildBoardProjectFilterPredicate, + countBoardColumnThreads, + deriveBoardColumn, + parseBoardWorktreeGroupDragId, + resolveBoardDropIntent, + sliceBoardSettledItems, + sortBoardThreads, + type BoardColumnItem, + type BoardColumnInput, +} from "./Board.logic"; + +const localEnvironmentId = EnvironmentId.make("environment-local"); +const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + +function columnThreadIds( + items: readonly BoardColumnItem[], +): string[] { + return items.flatMap((item) => + item.kind === "thread" ? [item.thread.id] : item.threads.map((thread) => thread.id), + ); +} + +function makeGitStatus(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/board", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + ...overrides, + }; +} + +function makePr(state: "open" | "closed" | "merged"): NonNullable { + return { + number: 42, + title: "Board view", + url: "https://github.com/example/repo/pull/42", + baseRef: "main", + headRef: "feature/board", + state, + }; +} + +function makeColumnInput(overrides: Partial = {}): BoardColumnInput { + return { + threadStatusLabel: null, + interactionMode: "default", + isSettled: false, + latestTurnCompletedAt: null, + readySessionUpdatedAt: null, + lastVisitedAt: null, + threadBranch: "feature/board", + hasDedicatedWorktree: false, + hasWorkingThreadForWorktree: false, + gitStatus: makeGitStatus(), + ...overrides, + }; +} + +describe("deriveBoardColumn", () => { + it("puts attention status pills in review ahead of working or merged lifecycle state", () => { + const gitStatus = makeGitStatus({ pr: makePr("merged") }); + expect( + deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Pending Approval", gitStatus })), + ).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Awaiting Input", gitStatus })), + ).toBe("review"); + expect( + deriveBoardColumn( + makeColumnInput({ + interactionMode: "plan", + threadStatusLabel: "Plan Ready", + gitStatus, + }), + ), + ).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Completed", gitStatus }))).toBe( + "review", + ); + }); + + it("puts working and connecting status pills in working, even with a merged PR", () => { + const gitStatus = makeGitStatus({ pr: makePr("merged") }); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Working", gitStatus }))).toBe( + "working", + ); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Connecting", gitStatus }))).toBe( + "working", + ); + }); + + it("defaults to review while git status is unloaded or the cwd is not a repo", () => { + expect(deriveBoardColumn(makeColumnInput({ gitStatus: null }))).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ isRepo: false }) })), + ).toBe("review"); + }); + + it("ignores git status from a shared cwd checked out on a different branch", () => { + const gitStatus = makeGitStatus({ + refName: "someone-elses-branch", + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect(deriveBoardColumn(makeColumnInput({ gitStatus }))).toBe("review"); + }); + + it("applies git status from a dedicated worktree regardless of ref name", () => { + const gitStatus = makeGitStatus({ refName: "detached-head", hasWorkingTreeChanges: true }); + expect(deriveBoardColumn(makeColumnInput({ hasDedicatedWorktree: true, gitStatus }))).toBe( + "review", + ); + }); + + it("puts a branch ahead of upstream in review", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ aheadCount: 2 }) })), + ).toBe("review"); + }); + + it("puts a never-pushed branch ahead of (or with unknown distance to) the default in review", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + gitStatus: makeGitStatus({ hasUpstream: false, aheadOfDefaultCount: 3 }), + }), + ), + ).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ hasUpstream: false }) })), + ).toBe("review"); + }); + + it("puts a clean fully pushed feature branch without a PR in published", () => { + expect(deriveBoardColumn(makeColumnInput())).toBe("published"); + }); + + it("puts an open PR with unpublished work in review", () => { + const gitStatus = makeGitStatus({ + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect(deriveBoardColumn(makeColumnInput({ gitStatus }))).toBe("review"); + }); + + it("does not move a sibling thread to review for a dirty worktree that is still working", () => { + const gitStatus = makeGitStatus({ + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect( + deriveBoardColumn( + makeColumnInput({ + hasDedicatedWorktree: true, + hasWorkingThreadForWorktree: true, + gitStatus, + }), + ), + ).toBe("published"); + }); + + it("still moves locally-ahead siblings to review while their worktree is working", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + hasDedicatedWorktree: true, + hasWorkingThreadForWorktree: true, + gitStatus: makeGitStatus({ aheadCount: 1, pr: makePr("open") }), + }), + ), + ).toBe("review"); + }); + + it("puts a clean open PR in published", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ pr: makePr("open") }) })), + ).toBe("published"); + }); + + it("keeps an unsettled merged PR in published instead of guessing settled", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ pr: makePr("merged") }) })), + ).toBe("published"); + }); + + it("lets the settled flag win regardless of git or completion state", () => { + expect(deriveBoardColumn(makeColumnInput({ isSettled: true, gitStatus: null }))).toBe( + "settled", + ); + expect( + deriveBoardColumn( + makeColumnInput({ + isSettled: true, + gitStatus: makeGitStatus({ hasWorkingTreeChanges: true, aheadCount: 1 }), + }), + ), + ).toBe("settled"); + expect( + deriveBoardColumn(makeColumnInput({ isSettled: true, threadStatusLabel: "Completed" })), + ).toBe("settled"); + }); + + it("puts an unseen turn completion in review regardless of git state", () => { + const unseen = { + latestTurnCompletedAt: "2026-07-22T10:00:00.000Z", + lastVisitedAt: "2026-07-22T09:00:00.000Z", + }; + expect(deriveBoardColumn(makeColumnInput({ ...unseen, gitStatus: null }))).toBe("review"); + expect( + deriveBoardColumn( + makeColumnInput({ ...unseen, gitStatus: makeGitStatus({ pr: makePr("merged") }) }), + ), + ).toBe("review"); + }); + + it("keeps a working status pill in working even with an unseen completion", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + threadStatusLabel: "Working", + latestTurnCompletedAt: "2026-07-22T10:00:00.000Z", + lastVisitedAt: "2026-07-22T09:00:00.000Z", + }), + ), + ).toBe("working"); + }); + + it("keeps a visited ready session in review when its latest turn summary is unavailable", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + latestTurnCompletedAt: null, + readySessionUpdatedAt: "2026-07-22T03:45:04.819Z", + lastVisitedAt: "2026-07-22T03:45:04.819Z", + gitStatus: null, + }), + ), + ).toBe("review"); + }); + + it("lets in-flight git work outrank a seen completion", () => { + const seen = { + latestTurnCompletedAt: "2026-07-22T09:00:00.000Z", + lastVisitedAt: "2026-07-22T10:00:00.000Z", + }; + expect( + deriveBoardColumn( + makeColumnInput({ ...seen, gitStatus: makeGitStatus({ hasWorkingTreeChanges: true }) }), + ), + ).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ ...seen }))).toBe("published"); + }); + + it("keeps a plan-only thread's column off its worktree's git state", () => { + // This git state would classify as published; a plan-mode thread does not + // own it (the implementation thread does), so the plan thread stays in + // review until settled. + const badgelessPlan = { + interactionMode: "plan" as const, + threadStatusLabel: null, + hasDedicatedWorktree: true, + gitStatus: makeGitStatus({ pr: makePr("open") }), + }; + expect( + deriveBoardColumn(makeColumnInput({ ...badgelessPlan, interactionMode: "default" })), + ).toBe("published"); + expect(deriveBoardColumn(makeColumnInput(badgelessPlan))).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ ...badgelessPlan, isSettled: true }))).toBe( + "settled", + ); + }); + + it("puts a clean default branch in review", () => { + const gitStatus = makeGitStatus({ refName: "main", isDefaultRef: true }); + expect(deriveBoardColumn(makeColumnInput({ threadBranch: "main", gitStatus }))).toBe("review"); + }); +}); + +describe("sortBoardThreads", () => { + const byUpdatedAt = (thread: { updatedAt: string }) => thread.updatedAt; + + it("orders by the selected timestamp descending", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-1", updatedAt: "2026-07-20T10:00:00.000Z" }, + { id: "thread-2", updatedAt: "2026-07-21T10:00:00.000Z" }, + { id: "thread-3", updatedAt: "2026-07-19T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-2", "thread-1", "thread-3"]); + }); + + it("breaks timestamp ties by thread id", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-b", updatedAt: "2026-07-20T10:00:00.000Z" }, + { id: "thread-a", updatedAt: "2026-07-20T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-a", "thread-b"]); + }); + + it("sorts invalid timestamps last", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-1", updatedAt: "not-a-date" }, + { id: "thread-2", updatedAt: "2026-07-20T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-2", "thread-1"]); + }); +}); + +describe("buildBoardColumns", () => { + it("sorts working threads by active session start (falling back to update time) and other columns by update time", () => { + const threads = [ + { + id: "thread-review-old", + updatedAt: "2026-07-18T10:00:00.000Z", + workingStartedAt: null, + }, + { + id: "thread-review-new", + updatedAt: "2026-07-21T10:00:00.000Z", + workingStartedAt: null, + }, + { + id: "thread-working-old", + updatedAt: "2026-07-22T10:00:00.000Z", + workingStartedAt: "2026-07-19T10:00:00.000Z", + }, + { + id: "thread-working-new", + updatedAt: "2026-07-20T10:00:00.000Z", + workingStartedAt: "2026-07-21T10:00:00.000Z", + }, + { + id: "thread-working-fallback", + updatedAt: "2026-07-23T10:00:00.000Z", + workingStartedAt: null, + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => (thread.id.startsWith("thread-working") ? "working" : "review"), + (thread) => thread.workingStartedAt, + ); + expect(columnThreadIds(columns.review)).toEqual(["thread-review-new", "thread-review-old"]); + expect(columnThreadIds(columns.working)).toEqual([ + "thread-working-fallback", + "thread-working-new", + "thread-working-old", + ]); + expect(columns.published).toEqual([]); + expect(columns.settled).toEqual([]); + }); + + it("hosts shared groups in their earliest column and orders members by actual column then time", () => { + const threads = [ + { + id: "group-review-old", + updatedAt: "2026-07-19T10:00:00.000Z", + workingStartedAt: null, + column: "review" as const, + groupKey: "shared-worktree", + }, + { + id: "group-working-old", + updatedAt: "2026-07-24T10:00:00.000Z", + workingStartedAt: "2026-07-20T10:00:00.000Z", + column: "working" as const, + groupKey: "shared-worktree", + }, + { + id: "group-published", + updatedAt: "2026-07-25T10:00:00.000Z", + workingStartedAt: null, + column: "published" as const, + groupKey: "shared-worktree", + }, + { + id: "group-review-new", + updatedAt: "2026-07-23T10:00:00.000Z", + workingStartedAt: null, + column: "review" as const, + groupKey: "shared-worktree", + }, + { + id: "group-working-new", + updatedAt: "2026-07-18T10:00:00.000Z", + workingStartedAt: "2026-07-22T10:00:00.000Z", + column: "working" as const, + groupKey: "shared-worktree", + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => thread.column, + (thread) => thread.workingStartedAt, + (thread) => thread.groupKey, + ); + + expect(columns.working).toEqual([ + { + kind: "worktreeGroup", + worktreeKey: "shared-worktree", + threads: [threads[4], threads[1], threads[3], threads[0], threads[2]], + }, + ]); + expect(columns.review).toEqual([]); + expect(columns.published).toEqual([]); + expect(columns.settled).toEqual([]); + }); + + it("uses the earliest represented column rather than moving every group to working", () => { + const threads = [ + { + id: "standalone-working", + updatedAt: "2026-07-22T10:00:00.000Z", + column: "working" as const, + groupKey: null, + }, + { + id: "group-settled", + updatedAt: "2026-07-23T10:00:00.000Z", + column: "settled" as const, + groupKey: "later-group", + }, + { + id: "group-review", + updatedAt: "2026-07-21T10:00:00.000Z", + column: "review" as const, + groupKey: "later-group", + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => thread.column, + () => null, + (thread) => thread.groupKey, + ); + + expect(columns.working).toEqual([{ kind: "thread", thread: threads[0] }]); + expect(columns.review).toEqual([ + { + kind: "worktreeGroup", + worktreeKey: "later-group", + threads: [threads[2], threads[1]], + }, + ]); + expect(columns.settled).toEqual([]); + }); +}); + +describe("countBoardColumnThreads", () => { + it("counts a worktree group as its member count", () => { + const items: BoardColumnItem<{ readonly id: string }>[] = [ + { kind: "thread", thread: { id: "thread-1" } }, + { + kind: "worktreeGroup", + worktreeKey: "shared-worktree", + threads: [{ id: "thread-2" }, { id: "thread-3" }], + }, + ]; + expect(countBoardColumnThreads(items)).toBe(3); + expect(countBoardColumnThreads([])).toBe(0); + }); +}); + +describe("sliceBoardSettledItems", () => { + const thread = (id: string): BoardColumnItem<{ readonly id: string }> => ({ + kind: "thread", + thread: { id }, + }); + const group = ( + worktreeKey: string, + ...ids: string[] + ): BoardColumnItem<{ readonly id: string }> => ({ + kind: "worktreeGroup", + worktreeKey, + threads: ids.map((id) => ({ id })), + }); + + it("returns the same array with no hidden count when the total fits the limit", () => { + const items = [thread("thread-1"), group("shared-worktree", "thread-2", "thread-3")]; + const result = sliceBoardSettledItems(items, 3); + expect(result.visibleItems).toBe(items); + expect(result.hiddenThreadCount).toBe(0); + }); + + it("slices plain thread items at the limit", () => { + const items = [thread("thread-1"), thread("thread-2"), thread("thread-3")]; + const result = sliceBoardSettledItems(items, 2); + expect(columnThreadIds(result.visibleItems)).toEqual(["thread-1", "thread-2"]); + expect(result.hiddenThreadCount).toBe(1); + }); + + it("includes a group straddling the limit whole and counts its members", () => { + const items = [ + thread("thread-1"), + group("shared-worktree", "thread-2", "thread-3", "thread-4"), + thread("thread-5"), + ]; + const result = sliceBoardSettledItems(items, 2); + expect(result.visibleItems).toEqual([items[0], items[1]]); + expect(result.hiddenThreadCount).toBe(1); + }); + + it("returns no visible items for a zero limit with non-empty input", () => { + const items = [thread("thread-1"), thread("thread-2")]; + const result = sliceBoardSettledItems(items, 0); + expect(result.visibleItems).toEqual([]); + expect(result.hiddenThreadCount).toBe(2); + }); +}); + +describe("buildBoardProjectFilterPredicate", () => { + const projectId = ProjectId.make("project-1"); + const otherProjectId = ProjectId.make("project-2"); + const snapshots = [ + { + projectKey: "logical-project-1", + memberProjectRefs: [ + scopeProjectRef(localEnvironmentId, projectId), + scopeProjectRef(remoteEnvironmentId, projectId), + ], + }, + ]; + + it("matches everything when no project is selected or the stored key no longer resolves", () => { + const noSelection = buildBoardProjectFilterPredicate({ selectedProjectKey: null, snapshots }); + expect(noSelection({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe( + true, + ); + const staleSelection = buildBoardProjectFilterPredicate({ + selectedProjectKey: "removed-project", + snapshots, + }); + expect(staleSelection({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe( + true, + ); + }); + + it("matches threads from any member project of the selected group", () => { + const predicate = buildBoardProjectFilterPredicate({ + selectedProjectKey: "logical-project-1", + snapshots, + }); + expect(predicate({ environmentId: localEnvironmentId, projectId })).toBe(true); + expect(predicate({ environmentId: remoteEnvironmentId, projectId })).toBe(true); + expect(predicate({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe(false); + }); +}); + +describe("boardWorktreeKey", () => { + it("returns null without a dedicated worktree", () => { + expect(boardWorktreeKey({ environmentId: localEnvironmentId, worktreePath: null })).toBeNull(); + expect(boardWorktreeKey({ environmentId: localEnvironmentId, worktreePath: " " })).toBeNull(); + }); +}); + +describe("resolveBoardDropIntent", () => { + it("maps the drop-zone droppables to their intents and everything else to null", () => { + expect(resolveBoardDropIntent(BOARD_ARCHIVE_DROPPABLE_ID)).toBe("archive"); + expect(resolveBoardDropIntent(BOARD_TRASH_DROPPABLE_ID)).toBe("trash"); + expect(resolveBoardDropIntent(BOARD_UNSETTLE_DROPPABLE_ID)).toBe("unsettle"); + expect(resolveBoardDropIntent(BOARD_SETTLED_COLUMN_DROPPABLE_ID)).toBe("settle"); + expect(resolveBoardDropIntent("board-column-review")).toBeNull(); + expect(resolveBoardDropIntent(null)).toBeNull(); + }); +}); + +describe("parseBoardWorktreeGroupDragId", () => { + it("round-trips the worktree key through the group drag id and rejects thread drag ids", () => { + const worktreeKey = boardWorktreeKey({ + environmentId: localEnvironmentId, + worktreePath: "/wt", + }); + expect(worktreeKey).not.toBeNull(); + const dragId = boardWorktreeGroupDragId(worktreeKey ?? ""); + + expect(dragId).not.toBe(worktreeKey); + expect(parseBoardWorktreeGroupDragId(dragId)).toBe(worktreeKey); + expect(parseBoardWorktreeGroupDragId("environment-local thread-1")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/board/Board.logic.ts b/apps/web/src/components/board/Board.logic.ts new file mode 100644 index 00000000000..930d1813407 --- /dev/null +++ b/apps/web/src/components/board/Board.logic.ts @@ -0,0 +1,386 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { + EnvironmentId, + ProjectId, + ProviderInteractionMode, + ScopedProjectRef, + VcsStatusResult, +} from "@t3tools/contracts"; +import { toSortableTimestamp } from "../../lib/threadSort"; +import { isCompletionUnseen, type ThreadStatusPill } from "../Sidebar.logic"; +import { resolveThreadPr } from "../ThreadStatusIndicators"; + +export type BoardColumnId = "working" | "review" | "published" | "settled"; + +export const BOARD_COLUMN_IDS: readonly BoardColumnId[] = [ + "working", + "review", + "published", + "settled", +]; + +export const BOARD_COLUMN_LABELS: Record = { + working: "Working", + review: "Review", + published: "Published", + settled: "Settled", +}; + +export const BOARD_TRASH_DROPPABLE_ID = "board-trash"; +export const BOARD_ARCHIVE_DROPPABLE_ID = "board-archive"; +export const BOARD_UNSETTLE_DROPPABLE_ID = "board-unsettle"; +export const BOARD_SETTLED_COLUMN_DROPPABLE_ID = "board-column-settled"; + +export type BoardDropIntent = "archive" | "trash" | "settle" | "unsettle"; + +/** Drag-overlay feedback per drop intent, shared by the card and group overlays. */ +export const BOARD_DROP_INTENT_OVERLAY_CLASSES: Record = { + archive: "scale-90 border-amber-500 opacity-60", + trash: "scale-90 border-destructive opacity-60", + settle: "scale-90 border-primary opacity-60", + unsettle: "scale-90 border-emerald-500 opacity-60", +}; + +/** + * Intent implied by the droppable currently under the pointer, or null when + * the drag is over neither zone. Drives feedback on the dragged card itself — + * the card usually covers the drop zone, hiding the zone's own highlight. + */ +export function resolveBoardDropIntent( + droppableId: string | number | null | undefined, +): BoardDropIntent | null { + if (droppableId === BOARD_ARCHIVE_DROPPABLE_ID) return "archive"; + if (droppableId === BOARD_TRASH_DROPPABLE_ID) return "trash"; + if (droppableId === BOARD_UNSETTLE_DROPPABLE_ID) return "unsettle"; + if (droppableId === BOARD_SETTLED_COLUMN_DROPPABLE_ID) return "settle"; + return null; +} + +const BOARD_WORKTREE_GROUP_DRAG_PREFIX = "board-worktree-group\u0000"; + +/** Draggable id for a whole worktree group; drops act on every member thread. */ +export function boardWorktreeGroupDragId(worktreeKey: string): string { + return `${BOARD_WORKTREE_GROUP_DRAG_PREFIX}${worktreeKey}`; +} + +/** Worktree key encoded in a group draggable id, or null for thread drags. */ +export function parseBoardWorktreeGroupDragId( + dragId: string | number | null | undefined, +): string | null { + return typeof dragId === "string" && dragId.startsWith(BOARD_WORKTREE_GROUP_DRAG_PREFIX) + ? dragId.slice(BOARD_WORKTREE_GROUP_DRAG_PREFIX.length) + : null; +} + +export interface BoardColumnInput { + threadStatusLabel: ThreadStatusPill["label"] | null; + interactionMode: ProviderInteractionMode; + isSettled: boolean; + latestTurnCompletedAt: string | null; + readySessionUpdatedAt: string | null; + lastVisitedAt: string | null; + threadBranch: string | null; + hasDedicatedWorktree: boolean; + hasWorkingThreadForWorktree: boolean; + gitStatus: VcsStatusResult | null; +} + +/** + * Whether the thread completed after the user's last visit. Falls back to the + * ready-session timestamp for providers whose shell cannot retain a + * latest-turn summary; both sources follow the sidebar's `isCompletionUnseen` + * rules. + */ +export function hasUnseenBoardCompletion( + input: Pick< + BoardColumnInput, + "latestTurnCompletedAt" | "readySessionUpdatedAt" | "lastVisitedAt" + >, +): boolean { + return isCompletionUnseen( + input.latestTurnCompletedAt ?? input.readySessionUpdatedAt, + input.lastVisitedAt, + ); +} + +/** + * Cache key for the board's aggregated VCS status map. Matches the dedupe + * granularity of the underlying subscription family: one entry per unique + * (environmentId, cwd) pair. + */ +export function boardGitKey(environmentId: EnvironmentId, cwd: string): string { + return `${environmentId}\u0000${cwd}`; +} + +/** + * Git status a thread may be attributed at all, or null. Threads sharing the + * project-root cwd must not inherit another branch's state, so without a + * dedicated worktree the checked-out ref has to match the thread's branch. + */ +export function resolveAppliedBoardGitStatus( + input: Pick, +): VcsStatusResult | null { + if (input.gitStatus === null || !input.gitStatus.isRepo) { + return null; + } + if (input.hasDedicatedWorktree) { + return input.gitStatus; + } + return input.threadBranch !== null && input.gitStatus.refName === input.threadBranch + ? input.gitStatus + : null; +} + +/** + * Lifecycle column for a thread. The server-backed settled flag is + * authoritative — safe to check first because `effectiveSettled` is never + * true for running or blocked threads, and it keeps a just-settled card from + * bouncing back to review on an unseen completion pill. Attention states win + * over the remaining lifecycle states: a thread blocked on the user + * (question/permission prompt) or holding an unseen completion sits in + * "review" regardless of git state. An actionable ready plan is also always + * reviewable. Git-driven columns still only move a card rightward as statuses + * stream in: unknown/unattributable git state falls through instead of + * guessing. + */ +export function deriveBoardColumn(input: BoardColumnInput): BoardColumnId { + if (input.isSettled) { + return "settled"; + } + + switch (input.threadStatusLabel) { + case "Pending Approval": + case "Awaiting Input": + case "Wake Required": + case "Plan Ready": + case "Completed": + return "review"; + case "Working": + case "Connecting": + return "working"; + case null: + break; + default: { + const exhaustiveStatusLabel: never = input.threadStatusLabel; + return exhaustiveStatusLabel; + } + } + + if (hasUnseenBoardCompletion(input)) { + return "review"; + } + + // A plan-mode thread does not own worktree changes made by the separate + // implementation thread that consumed its plan. Keep its column tied to + // its own attention/completion state instead of the shared worktree. + const gitStatus = input.interactionMode === "plan" ? null : resolveAppliedBoardGitStatus(input); + if (gitStatus !== null) { + const hasUnpublishedWork = + (gitStatus.hasWorkingTreeChanges && !input.hasWorkingThreadForWorktree) || + gitStatus.aheadCount > 0 || + (!gitStatus.hasUpstream && (gitStatus.aheadOfDefaultCount ?? 0) > 0); + if (hasUnpublishedWork) { + return "review"; + } + + // A merged PR is not special-cased here: it settles the thread through + // `effectiveSettled` upstream. When that is unavailable (pinned active, + // server without the settlement capability) the branch classifies by its + // git state alone, since it could not be moved out of Settled anyway. + const pr = resolveThreadPr(input.threadBranch, input.gitStatus); + const isCleanPushedFeatureBranch = + gitStatus.aheadCount === 0 && gitStatus.hasUpstream && !gitStatus.isDefaultRef; + if (pr?.state === "open" || isCleanPushedFeatureBranch) { + return "published"; + } + } + + return "review"; +} + +export interface BoardSortableThread { + readonly id: string; + readonly updatedAt: string; +} + +/** Sorts board threads newest-first by the timestamp selected for the column. */ +export function sortBoardThreads( + threads: readonly T[], + getSortTimestamp: (thread: T) => string | null, +): T[] { + return [...threads].sort((left, right) => { + const leftTimestamp = + toSortableTimestamp(getSortTimestamp(left) ?? undefined) ?? Number.NEGATIVE_INFINITY; + const rightTimestamp = + toSortableTimestamp(getSortTimestamp(right) ?? undefined) ?? Number.NEGATIVE_INFINITY; + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp > leftTimestamp ? 1 : -1; + } + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; + }); +} + +export type BoardColumnItem = + | { readonly kind: "thread"; readonly thread: T } + | { + readonly kind: "worktreeGroup"; + readonly worktreeKey: string; + readonly threads: readonly T[]; + }; + +/** + * Builds board items in lifecycle order. A shared group is emitted on its + * first encounter, which is its earliest column and most recent member there. + * Its members are already ordered by actual column, then that column's time. + */ +export function buildBoardColumns( + threads: readonly T[], + getColumn: (thread: T) => BoardColumnId, + getWorkingStartedAt: (thread: T) => string | null, + getGroupKey: (thread: T) => string | null = () => null, +): Record[]> { + const threadsByColumn: Record = { + working: [], + review: [], + published: [], + settled: [], + }; + for (const thread of threads) { + threadsByColumn[getColumn(thread)].push(thread); + } + for (const columnId of BOARD_COLUMN_IDS) { + threadsByColumn[columnId] = sortBoardThreads( + threadsByColumn[columnId], + columnId === "working" + ? (thread) => getWorkingStartedAt(thread) ?? thread.updatedAt + : (thread) => thread.updatedAt, + ); + } + + const groupMembersByKey = new Map(); + for (const columnId of BOARD_COLUMN_IDS) { + for (const thread of threadsByColumn[columnId]) { + const groupKey = getGroupKey(thread); + if (groupKey === null) { + continue; + } + const members = groupMembersByKey.get(groupKey); + if (members) { + members.push(thread); + } else { + groupMembersByKey.set(groupKey, [thread]); + } + } + } + + const columns: Record[]> = { + working: [], + review: [], + published: [], + settled: [], + }; + const emittedGroupKeys = new Set(); + for (const columnId of BOARD_COLUMN_IDS) { + for (const thread of threadsByColumn[columnId]) { + const groupKey = getGroupKey(thread); + const groupMembers = groupKey === null ? undefined : groupMembersByKey.get(groupKey); + if (groupKey === null || groupMembers === undefined || groupMembers.length < 2) { + columns[columnId].push({ kind: "thread", thread }); + continue; + } + if (emittedGroupKeys.has(groupKey)) { + continue; + } + emittedGroupKeys.add(groupKey); + columns[columnId].push({ + kind: "worktreeGroup", + worktreeKey: groupKey, + threads: groupMembers, + }); + } + } + return columns; +} + +/** Total threads across column items; a worktree group counts each member. */ +export function countBoardColumnThreads(items: readonly BoardColumnItem[]): number { + return items.reduce( + (count, item) => count + (item.kind === "thread" ? 1 : item.threads.length), + 0, + ); +} + +/** + * Settled-tail slice for the board column. The limit counts threads (a + * worktree group counts as its member count) so paging matches the sidebar's + * thread-based tail; a group straddling the limit is included whole since a + * group card cannot render partially. + */ +export function sliceBoardSettledItems( + items: readonly BoardColumnItem[], + limit: number, +): { visibleItems: readonly BoardColumnItem[]; hiddenThreadCount: number } { + const total = countBoardColumnThreads(items); + if (total <= limit) { + return { visibleItems: items, hiddenThreadCount: 0 }; + } + const visibleItems: BoardColumnItem[] = []; + let shown = 0; + for (const item of items) { + if (shown >= limit) break; + visibleItems.push(item); + shown += item.kind === "thread" ? 1 : item.threads.length; + } + return { visibleItems, hiddenThreadCount: total - shown }; +} + +export interface BoardWorktreeThread { + readonly environmentId: EnvironmentId; + readonly worktreePath: string | null; +} + +/** + * Identity of a thread's dedicated worktree for board grouping, or null when + * the thread runs in the shared project checkout. Only dedicated worktrees + * group: threads in the project root are unrelated lines of work even though + * they share a checkout. + */ +export function boardWorktreeKey(thread: BoardWorktreeThread): string | null { + const worktreePath = thread.worktreePath?.trim(); + if (!worktreePath) { + return null; + } + return `${thread.environmentId}\u0000${worktreePath}`; +} + +export interface BoardProjectFilterThread { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + +/** + * Predicate matching threads against a selected sidebar project group. + * Membership is by scoped project ref so locally+remotely-open copies of the + * same repository stay one entry, matching the sidebar's grouping. An + * unresolvable stored key (project removed, grouping changed) matches + * everything, i.e. falls back to "All projects". + */ +export function buildBoardProjectFilterPredicate(input: { + selectedProjectKey: string | null; + snapshots: ReadonlyArray<{ + readonly projectKey: string; + readonly memberProjectRefs: readonly ScopedProjectRef[]; + }>; +}): (thread: BoardProjectFilterThread) => boolean { + const selectedSnapshot = + input.selectedProjectKey === null + ? null + : (input.snapshots.find((snapshot) => snapshot.projectKey === input.selectedProjectKey) ?? + null); + if (selectedSnapshot === null) { + return () => true; + } + const memberKeys = new Set(selectedSnapshot.memberProjectRefs.map(scopedProjectKey)); + return (thread) => + memberKeys.has(scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId))); +} diff --git a/apps/web/src/components/board/BoardCard.test.tsx b/apps/web/src/components/board/BoardCard.test.tsx new file mode 100644 index 00000000000..5ed33b8c54e --- /dev/null +++ b/apps/web/src/components/board/BoardCard.test.tsx @@ -0,0 +1,200 @@ +import { DndContext } from "@dnd-kit/core"; +import { + DEFAULT_RUNTIME_MODE, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type VcsStatusResult, +} from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import type { SidebarThreadSummary } from "../../types"; +import { BoardCard, BoardCardDragOverlay } from "./BoardCard"; + +const environmentId = EnvironmentId.make("environment-local"); + +function makeThread(overrides: Partial = {}): SidebarThreadSummary { + return { + id: ThreadId.make("thread-1"), + environmentId, + projectId: ProjectId.make("project-1"), + title: "Fix board keyboard semantics", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: "default", + session: null, + createdAt: "2026-07-22T09:00:00.000Z", + updatedAt: "2026-07-22T10:00:00.000Z", + archivedAt: null, + latestTurn: null, + latestUserMessageAt: "2026-07-22T09:30:00.000Z", + branch: "feature/board", + worktreePath: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + settledOverride: null, + settledAt: null, + ...overrides, + }; +} + +function makeGitStatus(): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/board", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: { + number: 42, + title: "Board controls", + url: "https://github.com/example/repo/pull/42", + baseRef: "main", + headRef: "feature/board", + state: "open", + }, + }; +} + +function renderCard(thread: SidebarThreadSummary = makeThread(), isSettled = false): string { + return renderToStaticMarkup( + + {}} + onShowContextMenu={() => {}} + dragClickGuard={{ + startDrag: () => {}, + finishDrag: () => {}, + consumeSuppressedClick: () => false, + dispose: () => {}, + }} + /> + , + ); +} + +describe("BoardCard", () => { + it("uses a noninteractive card root and a native open-thread button", () => { + const markup = renderCard(); + const rootTag = markup.match(/]*data-testid="board-card-thread-1"[^>]*>/)?.[0]; + + expect(rootTag).toBeDefined(); + expect(rootTag).not.toContain('role="button"'); + expect(rootTag).not.toContain('tabindex="0"'); + expect(markup).toContain('", openButtonStart); + const prButtonStart = markup.indexOf(' + ) : ( + + #{pr.number} + + ) + ) : null} + {dirtyFileCount > 0 ? ( + + {dirtyFileCount} {dirtyFileCount === 1 ? "file" : "files"} + + ) : null} + {aheadCount > 0 ? ( + ↑{aheadCount} + ) : null} + {gitStatusPending ? ( + + ) : null} + + + {driverKind ? ( + + } + > + + + {modelLabel} + + ) : null} + +
+ + ); +} + +export const BoardCard = memo(function BoardCard({ + thread, + project, + gitStatus, + gitStatusPending, + isSettled, + onOpenThread, + onShowContextMenu, + dragClickGuard, +}: BoardCardProps) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: scopedThreadKey(threadRef), + data: { threadRef }, + }); + + const openThread = () => { + if (dragClickGuard.consumeSuppressedClick()) { + return; + } + onOpenThread(threadRef); + }; + + const showContextMenu = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onShowContextMenu(thread, { x: event.clientX, y: event.clientY }); + }; + + return ( +
+ { + event.stopPropagation(); + openThread(); + }} + /> +
+ ); +}); + +/** Non-interactive clone rendered inside the DragOverlay while dragging. */ +export function BoardCardDragOverlay({ + thread, + project, + gitStatus, + gitStatusPending, + isSettled, + dropIntent = null, +}: Pick & { + dropIntent?: BoardDropIntent | null; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/board/BoardColumn.tsx b/apps/web/src/components/board/BoardColumn.tsx new file mode 100644 index 00000000000..dbc77169a20 --- /dev/null +++ b/apps/web/src/components/board/BoardColumn.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { ScrollArea } from "../ui/scroll-area"; +import { BOARD_COLUMN_LABELS, type BoardColumnId } from "./Board.logic"; + +/** Small rounded count pill used by column headers and worktree groups. */ +export function BoardCountPill({ count, className }: { count: number; className?: string }) { + return ( + + {count} + + ); +} + +export function BoardColumn({ + columnId, + count, + children, +}: { + columnId: BoardColumnId; + count: number; + children: ReactNode; +}) { + return ( +
+
+ {BOARD_COLUMN_LABELS[columnId]} + +
+ {/* Horizontal touch pans must chain to the board's scroll container; + the viewport's default overscroll containment would swallow them. */} + +
+ {count === 0 ? ( +
+ No threads +
+ ) : ( + children + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/board/BoardDragClickGuard.test.ts b/apps/web/src/components/board/BoardDragClickGuard.test.ts new file mode 100644 index 00000000000..b35ef0580d0 --- /dev/null +++ b/apps/web/src/components/board/BoardDragClickGuard.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createBoardDragClickGuard } from "./BoardDragClickGuard"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("createBoardDragClickGuard", () => { + it("does not suppress ordinary clicks", () => { + const guard = createBoardDragClickGuard(); + + expect(guard.consumeSuppressedClick()).toBe(false); + }); + + it("suppresses the click generated when a drag finishes", () => { + const guard = createBoardDragClickGuard(); + + guard.startDrag(); + guard.finishDrag(); + + expect(guard.consumeSuppressedClick()).toBe(true); + expect(guard.consumeSuppressedClick()).toBe(false); + }); + + it("releases suppression when a finished drag produces no click", () => { + vi.useFakeTimers(); + const guard = createBoardDragClickGuard(); + + guard.startDrag(); + guard.finishDrag(); + vi.runOnlyPendingTimers(); + + expect(guard.consumeSuppressedClick()).toBe(false); + }); + + it("does not let an earlier reset clear suppression for a newer drag", () => { + vi.useFakeTimers(); + const guard = createBoardDragClickGuard(); + + guard.startDrag(); + guard.finishDrag(); + guard.startDrag(); + vi.runOnlyPendingTimers(); + + expect(guard.consumeSuppressedClick()).toBe(true); + }); +}); diff --git a/apps/web/src/components/board/BoardDragClickGuard.ts b/apps/web/src/components/board/BoardDragClickGuard.ts new file mode 100644 index 00000000000..cd99579db08 --- /dev/null +++ b/apps/web/src/components/board/BoardDragClickGuard.ts @@ -0,0 +1,47 @@ +export interface BoardDragClickGuard { + startDrag: () => void; + finishDrag: () => void; + consumeSuppressedClick: () => boolean; + dispose: () => void; +} + +/** + * Prevents the click synthesized by the pointer release that finishes a drag + * from opening a thread. If that release produces no click, the suppression + * expires on the next task instead of swallowing a later, intentional click. + */ +export function createBoardDragClickGuard(): BoardDragClickGuard { + let suppressClick = false; + let resetTimer: ReturnType | null = null; + + const clearResetTimer = () => { + if (resetTimer !== null) { + clearTimeout(resetTimer); + resetTimer = null; + } + }; + + return { + startDrag() { + clearResetTimer(); + suppressClick = true; + }, + finishDrag() { + clearResetTimer(); + resetTimer = setTimeout(() => { + suppressClick = false; + resetTimer = null; + }, 0); + }, + consumeSuppressedClick() { + if (!suppressClick) { + return false; + } + suppressClick = false; + return true; + }, + dispose() { + clearResetTimer(); + }, + }; +} diff --git a/apps/web/src/components/board/BoardDropZones.tsx b/apps/web/src/components/board/BoardDropZones.tsx new file mode 100644 index 00000000000..5c613567749 --- /dev/null +++ b/apps/web/src/components/board/BoardDropZones.tsx @@ -0,0 +1,78 @@ +import { useDroppable } from "@dnd-kit/core"; +import { ArchiveIcon, ArchiveRestoreIcon, Trash2Icon, type LucideIcon } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import { + BOARD_ARCHIVE_DROPPABLE_ID, + BOARD_TRASH_DROPPABLE_ID, + BOARD_UNSETTLE_DROPPABLE_ID, +} from "./Board.logic"; + +function BoardDropZone({ + droppableId, + testId, + label, + icon: Icon, + activeClass, +}: { + droppableId: string; + testId: string; + label: string; + icon: LucideIcon; + activeClass: string; +}) { + const { isOver, setNodeRef } = useDroppable({ id: droppableId }); + + return ( +
+ + {label} +
+ ); +} + +/** + * Floating droppables shown while a drag is active: archive and delete are + * always available, and restore (un-settle) appears when the drag includes a + * settled thread. Columns are derived state, so settling happens by dropping + * on the Settled column itself rather than a zone here. + */ +export function BoardDropZones({ showRestoreZone }: { showRestoreZone: boolean }) { + return ( + // w-max: shrink-to-fit against left:50% would otherwise cap the width at + // half the board and wrap the labels on narrow viewports. +
+ {showRestoreZone ? ( + + ) : null} + + +
+ ); +} diff --git a/apps/web/src/components/board/BoardScroll.test.ts b/apps/web/src/components/board/BoardScroll.test.ts new file mode 100644 index 00000000000..2a8ad4744e7 --- /dev/null +++ b/apps/web/src/components/board/BoardScroll.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { scrollBoardFromWheel, shouldScrollColumnFromWheel } from "./BoardScroll"; + +function makeContainer(overrides: Partial = {}): MockScrollContainer { + return { + clientWidth: 500, + scrollLeft: 200, + scrollWidth: 1_500, + ...overrides, + }; +} + +interface MockScrollContainer { + clientWidth: number; + scrollLeft: number; + scrollWidth: number; +} + +describe("shouldScrollColumnFromWheel", () => { + const verticalWheel = { deltaX: 0, deltaY: 50 }; + + it("keeps vertical wheel movement in a column that can scroll in that direction", () => { + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 200 }, + verticalWheel, + ), + ).toBe(true); + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 200 }, + { deltaX: 0, deltaY: -50 }, + ), + ).toBe(true); + }); + + it("releases vertical movement to the board at the matching column edge", () => { + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 500 }, + verticalWheel, + ), + ).toBe(false); + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 0 }, + { deltaX: 0, deltaY: -50 }, + ), + ).toBe(false); + }); + + it("releases horizontal gestures and non-overflowing columns to the board", () => { + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 200 }, + { deltaX: 50, deltaY: 10 }, + ), + ).toBe(false); + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 500, scrollTop: 0 }, + verticalWheel, + ), + ).toBe(false); + }); +}); + +describe("scrollBoardFromWheel", () => { + it("maps vertical wheel movement to horizontal board movement", () => { + const container = makeContainer(); + + expect( + scrollBoardFromWheel(container, { + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: 80, + }), + ).toBe(true); + expect(container.scrollLeft).toBe(280); + + scrollBoardFromWheel(container, { + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: -50, + }); + expect(container.scrollLeft).toBe(230); + + // Horizontal trackpad movement wins when it is the dominant axis. + scrollBoardFromWheel(container, { + ctrlKey: false, + deltaMode: 0, + deltaX: 60, + deltaY: 10, + }); + expect(container.scrollLeft).toBe(290); + }); + + it("normalizes line and page wheel deltas", () => { + const lineContainer = makeContainer(); + const pageContainer = makeContainer(); + + scrollBoardFromWheel(lineContainer, { + ctrlKey: false, + deltaMode: 1, + deltaX: 0, + deltaY: 2, + }); + scrollBoardFromWheel(pageContainer, { + ctrlKey: false, + deltaMode: 2, + deltaX: 0, + deltaY: 1, + }); + + expect(lineContainer.scrollLeft).toBe(232); + expect(pageContainer.scrollLeft).toBe(700); + }); + + it("clamps movement at both ends while retaining ownership of board scrolling", () => { + const rightEdge = makeContainer({ scrollLeft: 990 }); + const leftEdge = makeContainer({ scrollLeft: 10 }); + + expect( + scrollBoardFromWheel(rightEdge, { + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: 100, + }), + ).toBe(true); + expect(rightEdge.scrollLeft).toBe(1_000); + + expect( + scrollBoardFromWheel(leftEdge, { + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: -100, + }), + ).toBe(true); + expect(leftEdge.scrollLeft).toBe(0); + }); + + it("leaves pinch zoom and non-overflowing boards untouched", () => { + const zoomContainer = makeContainer(); + const fittingContainer = makeContainer({ clientWidth: 1_500 }); + const wheel = { ctrlKey: false, deltaMode: 0, deltaX: 0, deltaY: 100 }; + + expect(scrollBoardFromWheel(zoomContainer, { ...wheel, ctrlKey: true })).toBe(false); + expect(zoomContainer.scrollLeft).toBe(200); + expect(scrollBoardFromWheel(fittingContainer, wheel)).toBe(false); + expect(fittingContainer.scrollLeft).toBe(200); + }); +}); diff --git a/apps/web/src/components/board/BoardScroll.ts b/apps/web/src/components/board/BoardScroll.ts new file mode 100644 index 00000000000..ed607d99793 --- /dev/null +++ b/apps/web/src/components/board/BoardScroll.ts @@ -0,0 +1,84 @@ +const PIXELS_PER_WHEEL_LINE = 16; + +interface BoardScrollContainer { + readonly clientWidth: number; + scrollLeft: number; + readonly scrollWidth: number; +} + +interface BoardWheelInput { + readonly ctrlKey: boolean; + readonly deltaMode: number; + readonly deltaX: number; + readonly deltaY: number; +} + +interface ColumnScrollContainer { + readonly clientHeight: number; + readonly scrollHeight: number; + readonly scrollTop: number; +} + +interface WheelAxes { + readonly deltaX: number; + readonly deltaY: number; +} + +/** Returns true when a vertical gesture can still move an overflowing column. */ +export function shouldScrollColumnFromWheel( + container: ColumnScrollContainer, + event: WheelAxes, +): boolean { + if ( + !Number.isFinite(event.deltaY) || + event.deltaY === 0 || + Math.abs(event.deltaX) >= Math.abs(event.deltaY) + ) { + return false; + } + + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + if (maxScrollTop === 0) { + return false; + } + + return event.deltaY < 0 ? container.scrollTop > 0 : container.scrollTop < maxScrollTop; +} + +/** + * Redirects the wheel's dominant axis into the board's native horizontal + * scroll position. Handling horizontal input here as well keeps nested column + * scroll areas from swallowing trackpad gestures before they reach the board. + */ +export function scrollBoardFromWheel( + container: BoardScrollContainer, + event: BoardWheelInput, +): boolean { + if (event.ctrlKey) { + // Ctrl+wheel is commonly a trackpad pinch gesture. Leave browser zoom alone. + return false; + } + + const maxScrollLeft = Math.max(0, container.scrollWidth - container.clientWidth); + if (maxScrollLeft === 0) { + return false; + } + + const dominantDelta = + Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (!Number.isFinite(dominantDelta) || dominantDelta === 0) { + return false; + } + + const multiplier = + event.deltaMode === 1 + ? PIXELS_PER_WHEEL_LINE + : event.deltaMode === 2 + ? container.clientWidth + : 1; + container.scrollLeft = Math.min( + maxScrollLeft, + Math.max(0, container.scrollLeft + dominantDelta * multiplier), + ); + return true; +} diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx new file mode 100644 index 00000000000..9a878847ffd --- /dev/null +++ b/apps/web/src/components/board/BoardView.tsx @@ -0,0 +1,994 @@ +import { + DndContext, + DragOverlay, + MouseSensor, + pointerWithin, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import type { ScopedThreadRef, VcsStatusResult } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; +import { isElectron } from "../../env"; +import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { useClientSettings } from "../../hooks/useSettings"; +import { useThreadActions } from "../../hooks/useThreadActions"; +import { cn } from "../../lib/utils"; +import { readLocalApi } from "../../localApi"; +import { selectProjectGroupingSettings } from "../../logicalProject"; +import { + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, +} from "../../sidebarProjectGrouping"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useServerConfigs, + useThreadShells, +} from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import type { Project, SidebarThreadSummary } from "../../types"; +import { useUiStateStore } from "../../uiStateStore"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; +import { ProjectFavicon, ProjectFaviconFallback } from "../ProjectFavicon"; +import { + SETTLED_TAIL_INITIAL_COUNT, + SETTLED_TAIL_PAGE_COUNT, + archiveSelectedThreadEntries, + buildSidebarV2ThreadContextMenuItems, + isThreadSettledForDisplay, + resolveThreadStatusPill, +} from "../Sidebar.logic"; +import { resolveSnoozePresets, snoozeWakeDescription } from "../Sidebar.snooze"; +import { resolveThreadPr } from "../ThreadStatusIndicators"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SidebarInset } from "../ui/sidebar"; +import { Spinner } from "../ui/spinner"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + BOARD_COLUMN_IDS, + boardGitKey, + boardWorktreeKey, + buildBoardColumns, + buildBoardProjectFilterPredicate, + countBoardColumnThreads, + deriveBoardColumn, + parseBoardWorktreeGroupDragId, + resolveBoardDropIntent, + sliceBoardSettledItems, + type BoardDropIntent, +} from "./Board.logic"; +import { BoardCard, BoardCardDragOverlay } from "./BoardCard"; +import { BoardColumn } from "./BoardColumn"; +import { createBoardDragClickGuard } from "./BoardDragClickGuard"; +import { BoardDropZones } from "./BoardDropZones"; +import { scrollBoardFromWheel, shouldScrollColumnFromWheel } from "./BoardScroll"; +import { BoardWorktreeGroup, BoardWorktreeGroupDragOverlay } from "./BoardWorktreeGroup"; +import { useBoardVcsStatuses, type BoardVcsTarget } from "./useBoardVcsStatuses"; + +const BOARD_PROJECT_FILTER_STORAGE_KEY = "t3code:board:project-filter:v1"; +const BOARD_PROJECT_FILTER_ALL = "all"; +const BoardProjectFilterSchema = Schema.NullOr(Schema.String); + +interface BoardThreadGitContext { + readonly project: Project | null; + readonly gitStatus: VcsStatusResult | null; + readonly gitStatusPending: boolean; +} + +/** Error toast for a failed thread action; interruptions and successes are silent. */ +function reportThreadActionFailure(result: AtomCommandResult, title: string) { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +export function BoardView() { + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + + return ( + +
+ {bootstrapped ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +} + +function BoardContent() { + const projects = useProjects(); + const threadShells = useThreadShells(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const serverConfigs = useServerConfigs(); + const { + archiveThread, + confirmAndDeleteThread, + confirmAndDeleteThreads, + renameThread, + settleThread, + snoozeThread, + unsettleThread, + unsnoozeThread, + } = useThreadActions(); + const handleNewThread = useNewThreadHandler(); + const navigate = useNavigate(); + const boardScrollRef = useRef(null); + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const [threadRenameTarget, setThreadRenameTarget] = useState(null); + const [threadRenameTitle, setThreadRenameTitle] = useState(""); + + useEffect(() => { + const scrollContainer = boardScrollRef.current; + if (scrollContainer === null) { + return; + } + + const handleWheel = (event: WheelEvent) => { + const columnScrollViewport = + event.target instanceof Element + ? event.target.closest( + '[data-testid^="board-column-"] [data-slot="scroll-area-viewport"]', + ) + : null; + if ( + columnScrollViewport instanceof HTMLElement && + shouldScrollColumnFromWheel(columnScrollViewport, event) + ) { + return; + } + + if (scrollBoardFromWheel(scrollContainer, event)) { + event.preventDefault(); + } + }; + + // React's delegated wheel events can be passive. This listener must be + // non-passive so gestures released by nested columns can move the board. + scrollContainer.addEventListener("wheel", handleWheel, { passive: false }); + return () => scrollContainer.removeEventListener("wheel", handleWheel); + }, []); + + const [storedProjectFilter, setStoredProjectFilter] = useLocalStorage( + BOARD_PROJECT_FILTER_STORAGE_KEY, + null, + BoardProjectFilterSchema, + ); + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const desktopLocalEnvironmentIds = useMemo( + () => + new Set( + environments + .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) + .map((environment) => environment.environmentId), + ), + [environments], + ); + const projectSnapshots = useMemo( + () => + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + }), + [ + desktopLocalEnvironmentIds, + environmentLabelById, + primaryEnvironmentId, + projectGroupingSettings, + projects, + ], + ); + + const projectByKey = useMemo( + () => + new Map( + projects.map( + (project) => + [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + project, + ] as const, + ), + ), + [projects], + ); + + const threads = useMemo( + () => threadShells.filter((thread) => thread.archivedAt === null), + [threadShells], + ); + const filterPredicate = useMemo( + () => + buildBoardProjectFilterPredicate({ + selectedProjectKey: storedProjectFilter, + snapshots: projectSnapshots, + }), + [projectSnapshots, storedProjectFilter], + ); + const filteredThreads = useMemo( + () => threads.filter(filterPredicate), + [filterPredicate, threads], + ); + + const resolveThreadGitCwd = useCallback( + (thread: SidebarThreadSummary): string | null => { + if (thread.branch == null) { + return null; + } + const project = projectByKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + return thread.worktreePath ?? project?.workspaceRoot ?? null; + }, + [projectByKey], + ); + + const vcsTargets = useMemo( + () => + filteredThreads.flatMap((thread) => { + const cwd = resolveThreadGitCwd(thread); + return cwd === null ? [] : [{ environmentId: thread.environmentId, cwd }]; + }), + [filteredThreads, resolveThreadGitCwd], + ); + const gitStatuses = useBoardVcsStatuses(vcsTargets); + + const getThreadGitContext = useCallback( + (thread: SidebarThreadSummary): BoardThreadGitContext => { + const project = + projectByKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? null; + const cwd = resolveThreadGitCwd(thread); + const gitStatus = + cwd === null ? null : (gitStatuses.get(boardGitKey(thread.environmentId, cwd)) ?? null); + return { + project, + gitStatus, + gitStatusPending: cwd !== null && gitStatus === null, + }; + }, + [gitStatuses, projectByKey, resolveThreadGitCwd], + ); + + const nowMinute = useNowMinute(); + + const previousSettledThreadKeys = useRef>(new Set()); + const settledThreadKeys = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const keys = new Set(); + for (const thread of filteredThreads) { + const changeRequestState = + resolveThreadPr(thread.branch, getThreadGitContext(thread).gitStatus)?.state ?? null; + if ( + isThreadSettledForDisplay(thread, { + serverConfigs, + now, + autoSettleAfterDays, + changeRequestState, + }) + ) { + keys.add(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + } + } + // Column building and the drag handlers key on this set; keep the previous + // identity when the contents did not change so the minute tick and + // git-status churn don't cascade a full board re-render. + const previous = previousSettledThreadKeys.current; + if (previous.size === keys.size && [...keys].every((key) => previous.has(key))) { + return previous; + } + previousSettledThreadKeys.current = keys; + return keys; + }, [autoSettleAfterDays, filteredThreads, getThreadGitContext, nowMinute, serverConfigs]); + // The context-menu handler reads these through refs: depending on the live + // identities would hand every BoardCard a fresh callback prop on each + // git-status or shell event and defeat the cards' memoization. + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + const serverConfigsRef = useRef(serverConfigs); + serverConfigsRef.current = serverConfigs; + + const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + const threadStatusLabelByKey = useMemo( + () => + new Map( + threads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const threadStatusLabel = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt: threadLastVisitedAtById[threadKey], + }, + })?.label; + return [threadKey, threadStatusLabel ?? null] as const; + }), + ), + [threadLastVisitedAtById, threads], + ); + const workingWorktreeKeys = useMemo(() => { + const keys = new Set(); + for (const thread of threads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const statusLabel = threadStatusLabelByKey.get(threadKey); + if (statusLabel !== "Working" && statusLabel !== "Connecting") { + continue; + } + const cwd = resolveThreadGitCwd(thread); + if (cwd !== null) { + keys.add(boardGitKey(thread.environmentId, cwd)); + } + } + return keys; + }, [resolveThreadGitCwd, threadStatusLabelByKey, threads]); + const columns = useMemo( + () => + buildBoardColumns( + filteredThreads, + (thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = threadLastVisitedAtById[threadKey]; + const cwd = resolveThreadGitCwd(thread); + + return deriveBoardColumn({ + threadStatusLabel: threadStatusLabelByKey.get(threadKey) ?? null, + interactionMode: thread.interactionMode, + isSettled: settledThreadKeys.has(threadKey), + latestTurnCompletedAt: thread.latestTurn?.completedAt ?? null, + readySessionUpdatedAt: + thread.latestTurn === null && thread.session?.status === "ready" + ? thread.session.updatedAt + : null, + lastVisitedAt: lastVisitedAt ?? null, + threadBranch: thread.branch, + hasDedicatedWorktree: thread.worktreePath != null, + hasWorkingThreadForWorktree: + cwd !== null && workingWorktreeKeys.has(boardGitKey(thread.environmentId, cwd)), + gitStatus: getThreadGitContext(thread).gitStatus, + }); + }, + (thread) => + thread.session?.status === "running" && + thread.latestTurn?.turnId === thread.session.activeTurnId + ? (thread.latestTurn.startedAt ?? thread.latestTurn.requestedAt) + : null, + boardWorktreeKey, + ), + [ + filteredThreads, + getThreadGitContext, + resolveThreadGitCwd, + settledThreadKeys, + threadLastVisitedAtById, + threadStatusLabelByKey, + workingWorktreeKeys, + ], + ); + + // Settled tail renders in pages, mirroring SidebarV2: expansion resets when + // the project filter changes so a scope flip never inherits a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const settledResetKey = storedProjectFilter ?? "all"; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + const settledTail = useMemo( + () => sliceBoardSettledItems(columns.settled, settledVisibleCount), + [columns, settledVisibleCount], + ); + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + + const dragClickGuard = useMemo(() => createBoardDragClickGuard(), []); + useEffect(() => () => dragClickGuard.dispose(), [dragClickGuard]); + const [activeDragId, setActiveDragId] = useState(null); + const [dropIntent, setDropIntent] = useState(null); + const activeThread = useMemo( + () => + activeDragId === null || parseBoardWorktreeGroupDragId(activeDragId) !== null + ? null + : (filteredThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeDragId, + ) ?? null), + [activeDragId, filteredThreads], + ); + const activeWorktreeGroup = useMemo(() => { + const worktreeKey = parseBoardWorktreeGroupDragId(activeDragId); + if (worktreeKey === null) { + return null; + } + for (const columnId of BOARD_COLUMN_IDS) { + for (const item of columns[columnId]) { + if (item.kind === "worktreeGroup" && item.worktreeKey === worktreeKey) { + return item; + } + } + } + return null; + }, [activeDragId, columns]); + const activeDragIncludesSettledThread = useMemo(() => { + if (activeDragId === null) { + return false; + } + if (activeWorktreeGroup !== null) { + return activeWorktreeGroup.threads.some((thread) => + settledThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + } + return settledThreadKeys.has(activeDragId); + }, [activeDragId, activeWorktreeGroup, settledThreadKeys]); + + // Touch drags require a long-press so swipe gestures keep scrolling the + // board; a distance-only constraint would claim every swipe as a drag. + const dndSensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }), + ); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + dragClickGuard.startDrag(); + setActiveDragId(String(event.active.id)); + }, + [dragClickGuard], + ); + + const handleDragOver = useCallback((event: DragOverEvent) => { + setDropIntent(resolveBoardDropIntent(event.over?.id)); + }, []); + + const handleDragCancel = useCallback(() => { + dragClickGuard.finishDrag(); + setActiveDragId(null); + setDropIntent(null); + }, [dragClickGuard]); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + dragClickGuard.finishDrag(); + setActiveDragId(null); + setDropIntent(null); + const dragData = event.active.data.current as + | { + threadRef?: ScopedThreadRef; + worktreeGroupThreadRefs?: readonly ScopedThreadRef[]; + } + | undefined; + const threadRefs = + dragData?.worktreeGroupThreadRefs ?? (dragData?.threadRef ? [dragData.threadRef] : []); + if (threadRefs.length === 0) { + return; + } + const intent = resolveBoardDropIntent(event.over?.id); + if (intent === null) { + return; + } + + if (intent === "settle" || intent === "unsettle") { + // Dropping onto a state a card is already in is a no-op: the restore + // zone only shows for settled drags, and a group can hold a mix. + const wantSettled = intent === "settle"; + const refs = threadRefs.filter( + (threadRef) => settledThreadKeys.has(scopedThreadKey(threadRef)) !== wantSettled, + ); + if (refs.length === 0) { + return; + } + const action = wantSettled ? settleThread : unsettleThread; + const failureTitle = `Failed to ${wantSettled ? "settle" : "restore"} ${ + refs.length === 1 ? "thread" : "threads" + }`; + // Success is silent: the card moves when the shell update streams in. + void Promise.all(refs.map((threadRef) => action(threadRef))).then((results) => { + const failure = results.find( + (result) => result._tag === "Failure" && !isAtomCommandInterrupted(result), + ); + if (failure) { + reportThreadActionFailure(failure, failureTitle); + } + }); + return; + } + + if (intent === "trash") { + void confirmAndDeleteThreads(threadRefs).then((result) => { + reportThreadActionFailure( + result, + threadRefs.length === 1 ? "Failed to delete thread" : "Failed to delete threads", + ); + }); + return; + } + + void archiveSelectedThreadEntries({ + entries: threadRefs.map((threadRef) => ({ + threadKey: scopedThreadKey(threadRef), + threadRef, + })), + archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }), + }).then((outcome) => { + for (const failure of outcome.followupFailures) { + reportThreadActionFailure(failure, "Thread archived, but navigation failed"); + } + if (outcome.mutationFailure) { + reportThreadActionFailure( + outcome.mutationFailure, + threadRefs.length === 1 ? "Failed to archive thread" : "Failed to archive threads", + ); + } + }); + }, + [ + archiveThread, + confirmAndDeleteThreads, + dragClickGuard, + settledThreadKeys, + settleThread, + unsettleThread, + ], + ); + + const handleOpenThread = useCallback( + (threadRef: ScopedThreadRef) => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [navigate], + ); + + const closeThreadRenameDialog = useCallback(() => { + setThreadRenameTarget(null); + setThreadRenameTitle(""); + }, []); + + const submitThreadRename = useCallback(async () => { + if (threadRenameTarget === null) { + return; + } + const committed = await renameThread( + scopeThreadRef(threadRenameTarget.environmentId, threadRenameTarget.id), + threadRenameTitle, + threadRenameTarget.title, + ); + if (committed) { + closeThreadRenameDialog(); + } + }, [closeThreadRenameDialog, renameThread, threadRenameTarget, threadRenameTitle]); + + const handleThreadContextMenu = useCallback( + async (thread: SidebarThreadSummary, position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) { + return; + } + + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const supportsSettlement = + serverConfigsRef.current.get(thread.environmentId)?.environment.capabilities + .threadSettlement === true; + const supportsSnooze = + serverConfigsRef.current.get(thread.environmentId)?.environment.capabilities + .threadSnooze === true; + const isSettled = settledThreadKeysRef.current.has(threadKey); + // Snooze classification uses a real clock (wake times are + // second-precise) and presets resolve at menu-open time — both same + // as the sidebar. + const preciseNow = new Date().toISOString(); + const isSnoozed = supportsSnooze && effectiveSnoozed(thread, { now: preciseNow }); + const snoozePresets = resolveSnoozePresets(new Date()); + const clicked = await api.contextMenu.show( + buildSidebarV2ThreadContextMenuItems({ + branch: thread.branch, + supportsSettlement, + isSettled, + supportsSnooze, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: preciseNow }), + snoozePresets, + }), + position, + ); + + if (clicked?.startsWith("snooze:")) { + const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked); + if (!preset) { + return; + } + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + reportThreadActionFailure(result, "Failed to snooze thread"); + return; + } + // A snoozed card has no visible change on the board, so the toast is + // the only confirmation — and Undo the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + void unsnoozeThread(threadRef).then((undoResult) => { + reportThreadActionFailure(undoResult, "Failed to wake thread"); + }); + }, + }, + }), + ); + return; + } + if (clicked === "unsnooze") { + reportThreadActionFailure(await unsnoozeThread(threadRef), "Failed to wake thread"); + return; + } + if (clicked === "new-thread-on-branch") { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }); + return; + } + if (clicked === "settle" || clicked === "unsettle") { + const result = + clicked === "settle" ? await settleThread(threadRef) : await unsettleThread(threadRef); + reportThreadActionFailure( + result, + clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", + ); + return; + } + + if (clicked === "rename") { + setThreadRenameTarget(thread); + setThreadRenameTitle(thread.title); + return; + } + if (clicked === "mark-unread") { + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + } + if (clicked !== "delete") { + return; + } + + reportThreadActionFailure(await confirmAndDeleteThread(threadRef), "Failed to delete thread"); + }, + [ + confirmAndDeleteThread, + handleNewThread, + markThreadUnread, + settleThread, + snoozeThread, + unsettleThread, + unsnoozeThread, + ], + ); + + const showThreadContextMenu = useCallback( + (thread: SidebarThreadSummary, position: { x: number; y: number }) => { + void settlePromise(() => handleThreadContextMenu(thread, position)).then((result) => { + if (result._tag === "Success") { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, + [handleThreadContextMenu], + ); + + const projectFilterItems = useMemo( + () => [ + { value: BOARD_PROJECT_FILTER_ALL, label: "All projects" }, + ...projectSnapshots.map((snapshot) => ({ + value: snapshot.projectKey, + label: snapshot.displayName, + })), + ], + [projectSnapshots], + ); + const selectedFilterValue = + storedProjectFilter !== null && + projectSnapshots.some((snapshot) => snapshot.projectKey === storedProjectFilter) + ? storedProjectFilter + : BOARD_PROJECT_FILTER_ALL; + const selectedFilterSnapshot = + selectedFilterValue === BOARD_PROJECT_FILTER_ALL + ? null + : (projectSnapshots.find((snapshot) => snapshot.projectKey === selectedFilterValue) ?? null); + + return ( + <> + {/* .workspace-topbar pins the header to --workspace-topbar-height so the + floating sidebar toggle (absolutely positioned in that same band) + stays vertically aligned with the title at every breakpoint. */} +
+ Board +
+ +
+
+ +
+
+
+ {BOARD_COLUMN_IDS.map((columnId) => { + const items = columnId === "settled" ? settledTail.visibleItems : columns[columnId]; + return ( + + {items.map((item) => { + const renderCard = (thread: SidebarThreadSummary) => { + const gitContext = getThreadGitContext(thread); + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + + ); + }; + if (item.kind === "thread") { + return renderCard(item.thread); + } + // buildBoardColumns only emits groups with >= 2 members. + const mostRecentThread = item.threads[0]!; + return ( + + scopeThreadRef(thread.environmentId, thread.id), + )} + worktreePath={mostRecentThread.worktreePath ?? ""} + branch={mostRecentThread.branch} + mostRecentCard={renderCard(mostRecentThread)} + dragClickGuard={dragClickGuard} + > + {item.threads.slice(1).map(renderCard)} + + ); + })} + {columnId === "settled" && settledTail.hiddenThreadCount > 0 ? ( + + ) : null} + + ); + })} +
+
+ {activeDragId !== null ? ( + + ) : null} +
+ + {activeWorktreeGroup !== null ? ( + + ) : activeThread !== null ? ( + + ) : null} + +
+ { + if (!open) { + closeThreadRenameDialog(); + } + }} + > + + + Rename thread + Update the title shown for this thread. + + +
+ Thread title + setThreadRenameTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitThreadRename(); + } + }} + /> +
+
+ + + + +
+
+ + ); +} diff --git a/apps/web/src/components/board/BoardWorktreeGroup.test.tsx b/apps/web/src/components/board/BoardWorktreeGroup.test.tsx new file mode 100644 index 00000000000..a1f5f1b67cf --- /dev/null +++ b/apps/web/src/components/board/BoardWorktreeGroup.test.tsx @@ -0,0 +1,100 @@ +import { DndContext } from "@dnd-kit/core"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import type { BoardDragClickGuard } from "./BoardDragClickGuard"; +import { BoardWorktreeGroup, BoardWorktreeGroupDragOverlay } from "./BoardWorktreeGroup"; + +const environmentId = EnvironmentId.make("environment-local"); + +const noopDragClickGuard: BoardDragClickGuard = { + startDrag: () => {}, + finishDrag: () => {}, + consumeSuppressedClick: () => false, + dispose: () => {}, +}; + +function renderGroup({ + branch = "t3code/session-dashboard-board", + mostRecentCard =
, + children =
, +}: { + branch?: string | null; + mostRecentCard?: ReactNode; + children?: ReactNode; +} = {}): string { + return renderToStaticMarkup( + + + {children} + + , + ); +} + +describe("BoardWorktreeGroup", () => { + it("starts collapsed showing only the most recent card below the toggle", () => { + const markup = renderGroup(); + + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(' +
+ {mostRecentCard} + {expanded ? children : null} +
+ + ); +} + +/** Non-interactive header clone rendered inside the DragOverlay while dragging a group. */ +export function BoardWorktreeGroupDragOverlay({ + worktreePath, + branch, + threadCount, + dropIntent = null, +}: { + worktreePath: string; + branch: string | null; + threadCount: number; + dropIntent?: BoardDropIntent | null; +}) { + const displayLabel = resolveWorktreeGroupLabel(worktreePath, branch); + + return ( + + ); +} diff --git a/apps/web/src/components/board/useBoardVcsStatuses.ts b/apps/web/src/components/board/useBoardVcsStatuses.ts new file mode 100644 index 00000000000..b774880b651 --- /dev/null +++ b/apps/web/src/components/board/useBoardVcsStatuses.ts @@ -0,0 +1,78 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, VcsStatusResult } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo, useRef } from "react"; + +import { vcsEnvironment } from "../../state/vcs"; +import { boardGitKey } from "./Board.logic"; + +export interface BoardVcsTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; +} + +const EMPTY_STATUSES_ATOM = Atom.make( + (): ReadonlyMap => new Map(), +).pipe(Atom.withLabel("web:board-vcs-statuses:empty")); + +/** + * Aggregated VCS status subscription for the board: one derived atom over the + * per-cwd status subscription family, read with a single useAtomValue. The + * family dedupes identical (environmentId, cwd) keys into one WS subscription + * and keeps entries warm for 5 minutes after last use, so filter toggles + * don't churn subscriptions. Entries are `null` until the first snapshot + * streams in. + */ +export function useBoardVcsStatuses( + targets: ReadonlyArray, +): ReadonlyMap { + // Thread-shell churn hands this hook a fresh input array on every update; + // dedupe into a sorted, identity-stable list so the derived atom below is + // only rebuilt when the (environmentId, cwd) set actually changes. + const previousTargetsRef = useRef>([]); + const dedupedTargets = useMemo(() => { + const byKey = new Map(); + for (const target of targets) { + byKey.set(boardGitKey(target.environmentId, target.cwd), target); + } + const next = [...byKey.entries()].sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + const previous = previousTargetsRef.current; + if ( + previous.length === next.length && + previous.every(([key], index) => key === next[index]![0]) + ) { + return previous; + } + previousTargetsRef.current = next; + return next; + }, [targets]); + + const statusesAtom = useMemo(() => { + if (dedupedTargets.length === 0) { + return EMPTY_STATUSES_ATOM; + } + return Atom.make( + (get): ReadonlyMap => + new Map( + dedupedTargets.map(([key, target]) => [ + key, + Option.getOrNull( + AsyncResult.value( + get( + vcsEnvironment.status({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }), + ), + ), + ), + ]), + ), + ).pipe(Atom.withLabel(`web:board-vcs-statuses:${dedupedTargets.length}`)); + }, [dedupedTargets]); + + return useAtomValue(statusesAtom); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..816bca7b49c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -159,6 +159,7 @@ import { CircleAlertIcon, ListTodoIcon, PencilRulerIcon, + PlusIcon, type LucideIcon, LockIcon, LockOpenIcon, @@ -581,6 +582,7 @@ export interface ChatComposerProps { // Callbacks onSend: (e?: { preventDefault: () => void }) => void; + onStartNewThread: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( @@ -667,6 +669,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerElementContextsRef, onSend, + onStartNewThread, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, @@ -980,6 +983,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const imageFileInputRef = useRef(null); // ------------------------------------------------------------------ // Derived: composer send state @@ -1031,6 +1035,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (composerTrigger.kind === "slash-command") { const builtInSlashCommandItems = [ + { + id: "slash:new", + type: "slash-command", + command: "new", + label: "/new", + description: "Start a new thread in this worktree or directory", + }, { id: "slash:model", type: "slash-command", @@ -1660,6 +1671,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (item.type === "slash-command") { + if (item.command === "new") { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); + if (applied) { + setComposerHighlightedItemId(null); + onStartNewThread(); + } + return; + } if (item.command === "model") { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -1717,7 +1739,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } }, - [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], + [ + applyPromptReplacement, + handleInteractionModeChange, + onStartNewThread, + resolveActiveComposerTrigger, + ], ); const onComposerMenuItemHighlighted = useCallback( @@ -1913,6 +1940,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) removeComposerImageFromDraft(imageId); }; + const onImageFileInputChange = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + addComposerImages(files); + }; + // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ @@ -2672,18 +2705,38 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} {isComposerFooterCompact ? ( - + <> + + + + ) : ( <> {providerTraitsPicker ? ( diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index de5a2f7cfff..1febd75649b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,11 +1,68 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo } from "react"; +import { + EditorId, + EnvironmentId, + OpenWithEntry as OpenWithEntrySchema, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type OpenWithDirectoryMode, + type OpenWithEntry, + type OpenWithEntryKind, + type OpenWithEntryPresentation, + type OpenWithEntryRef, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + AppWindowIcon, + ChevronDownIcon, + Code2Icon, + FolderClosedIcon, + PlusIcon, + SettingsIcon, + SquareTerminalIcon, + Trash2Icon, + TriangleAlertIcon, +} from "lucide-react"; +import { memo, useCallback, useEffect, useId, useMemo, useState, type FormEvent } from "react"; + +import { readLegacyPreferredEditor } from "../../editorPreferences"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; -import { usePreferredEditor } from "../../editorPreferences"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { + mergeOpenWithOptions, + nextOpenWithEntryId, + refForOpenWithOption, + resolveEffectiveOpenWith, + type OpenWithOption, +} from "../../openWith"; +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { shellEnvironment } from "../../state/shell"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; import { Group, GroupSeparator } from "../ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { AntigravityIcon, CursorIcon, @@ -31,156 +88,157 @@ import { RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; -import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; -import { shellEnvironment } from "~/state/shell"; -import { useAtomCommand } from "~/state/use-atom-command"; - -type OpenInOption = { - label: string; - Icon: Icon; - value: EditorId; - kind: "brand" | "generic"; +import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; + +function desktopEditorUrlScheme(editor: EditorId): string | null { + switch (editor) { + case "vscode": + return "vscode"; + case "vscode-insiders": + return "vscode-insiders"; + case "cursor": + return "cursor"; + default: + return null; + } +} + +export function resolveDesktopEditorUri(editor: EditorId, cwd: string): string | null { + const scheme = desktopEditorUrlScheme(editor); + if (!scheme || !cwd.startsWith("/")) return null; + const encodedPath = cwd.split("/").map(encodeURIComponent).join("/"); + return `${scheme}://file${encodedPath}?windowId=_blank`; +} + +type BuiltinPresentation = { + readonly label: string; + readonly Icon: Icon; + readonly value: EditorId; + readonly kind: "brand" | "generic"; }; -const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { - const baseOptions: ReadonlyArray = [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - kind: "brand", - }, - { - label: "Trae", - Icon: TraeIcon, - value: "trae", - kind: "brand", - }, - { - label: "Kiro", - Icon: KiroIcon, - value: "kiro", - kind: "brand", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - kind: "brand", - }, - { - label: "VS Code Insiders", - Icon: VisualStudioCodeInsiders, - value: "vscode-insiders", - kind: "brand", - }, - { - label: "VSCodium", - Icon: VSCodium, - value: "vscodium", - kind: "brand", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - kind: "brand", - }, - { - label: "Antigravity", - Icon: AntigravityIcon, - value: "antigravity", - kind: "brand", - }, - { - label: "IntelliJ IDEA", - Icon: IntelliJIdeaIcon, - value: "idea", - kind: "brand", - }, - { - label: "Aqua", - Icon: AquaIcon, - value: "aqua", - kind: "brand", - }, - { - label: "CLion", - Icon: CLionIcon, - value: "clion", - kind: "brand", - }, - { - label: "DataGrip", - Icon: DataGripIcon, - value: "datagrip", - kind: "brand", - }, - { - label: "DataSpell", - Icon: DataSpellIcon, - value: "dataspell", - kind: "brand", - }, - { - label: "GoLand", - Icon: GoLandIcon, - value: "goland", - kind: "brand", - }, - { - label: "PhpStorm", - Icon: PhpStormIcon, - value: "phpstorm", - kind: "brand", - }, - { - label: "PyCharm", - Icon: PyCharmIcon, - value: "pycharm", - kind: "brand", - }, - { - label: "Rider", - Icon: RiderIcon, - value: "rider", - kind: "brand", - }, - { - label: "RubyMine", - Icon: RubyMineIcon, - value: "rubymine", - kind: "brand", - }, - { - label: "RustRover", - Icon: RustRoverIcon, - value: "rustrover", - kind: "brand", - }, - { - label: "WebStorm", - Icon: WebStormIcon, - value: "webstorm", - kind: "brand", - }, - { - label: isMacPlatform(platform) - ? "Finder" - : isWindowsPlatform(platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - kind: "generic", - }, - ]; - const availableEditorSet = new Set(availableEditors); - return baseOptions.filter((option) => availableEditorSet.has(option.value)); +const BUILTIN_PRESENTATIONS: readonly BuiltinPresentation[] = [ + { label: "Cursor", Icon: CursorIcon, value: "cursor", kind: "brand" }, + { label: "Trae", Icon: TraeIcon, value: "trae", kind: "brand" }, + { label: "Kiro", Icon: KiroIcon, value: "kiro", kind: "brand" }, + { label: "VS Code", Icon: VisualStudioCode, value: "vscode", kind: "brand" }, + { + label: "VS Code Insiders", + Icon: VisualStudioCodeInsiders, + value: "vscode-insiders", + kind: "brand", + }, + { label: "VSCodium", Icon: VSCodium, value: "vscodium", kind: "brand" }, + { label: "Zed", Icon: Zed, value: "zed", kind: "brand" }, + { label: "Antigravity", Icon: AntigravityIcon, value: "antigravity", kind: "brand" }, + { label: "IntelliJ IDEA", Icon: IntelliJIdeaIcon, value: "idea", kind: "brand" }, + { label: "Aqua", Icon: AquaIcon, value: "aqua", kind: "brand" }, + { label: "CLion", Icon: CLionIcon, value: "clion", kind: "brand" }, + { label: "DataGrip", Icon: DataGripIcon, value: "datagrip", kind: "brand" }, + { label: "DataSpell", Icon: DataSpellIcon, value: "dataspell", kind: "brand" }, + { label: "GoLand", Icon: GoLandIcon, value: "goland", kind: "brand" }, + { label: "PhpStorm", Icon: PhpStormIcon, value: "phpstorm", kind: "brand" }, + { label: "PyCharm", Icon: PyCharmIcon, value: "pycharm", kind: "brand" }, + { label: "Rider", Icon: RiderIcon, value: "rider", kind: "brand" }, + { label: "RubyMine", Icon: RubyMineIcon, value: "rubymine", kind: "brand" }, + { label: "RustRover", Icon: RustRoverIcon, value: "rustrover", kind: "brand" }, + { label: "WebStorm", Icon: WebStormIcon, value: "webstorm", kind: "brand" }, + { label: "File Manager", Icon: FolderClosedIcon, value: "file-manager", kind: "generic" }, +]; + +const DESKTOP_PRIMARY_ENVIRONMENT_ID = EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID); + +const builtinPresentationById = new Map(BUILTIN_PRESENTATIONS.map((entry) => [entry.value, entry])); +const decodeOpenWithEntry = Schema.decodeUnknownSync(OpenWithEntrySchema); + +type ArgumentRow = { readonly id: string; readonly value: string }; +const makeArgumentRow = (value = ""): ArgumentRow => ({ id: randomUUID(), value }); + +const OPEN_WITH_KIND_LABELS: Record = { + editor: "Editor", + terminal: "Terminal", + "file-manager": "File manager", + other: "Other", }; -function getOpenInIconClass(kind: OpenInOption["kind"]) { - return cn(kind === "brand" ? "text-foreground opacity-100" : "text-muted-foreground"); +const DIRECTORY_MODE_LABELS: Record = { + "open-target": "Open target", + "working-directory": "Working directory", + "custom-arguments": "Custom arguments", +}; + +function categoryIcon(kind: OpenWithEntryKind): Icon { + if (kind === "editor") return Code2Icon; + if (kind === "terminal") return SquareTerminalIcon; + if (kind === "file-manager") return FolderClosedIcon; + return AppWindowIcon; +} + +function CustomIcon({ + entry, + presentation, + className, +}: { + entry: OpenWithEntry; + presentation: OpenWithEntryPresentation | null; + className?: string; +}) { + if (presentation?.iconDataUrl) { + return ; + } + const FallbackIcon = categoryIcon(entry.kind); + return