diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 306038007e9a..a11eee4e3f4e 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -43,10 +43,15 @@ prints one JSON line; on failure it names the next command. Mobile verification includes that step unless the user prohibits it. For a device you opened in an agent-device session elsewhere (a device pool, -cloud, or remote host), run `vp run mobile:install --session ` -from the directory you opened the session in. It installs the cached build into -that session (`--abi arm64-v8a` for an ARM Android device; `--metro ` also -opens it against Metro) and never builds. +cloud, or remote host), run `vp run mobile:install --session +--config ` from the directory you opened the session in, passing the +`--config` file from `device_open`'s `targetArgs`. Without it, a device pool's +gateway refuses the install, wrongly saying agent-device is too old. It installs +the cached build into that session (`--abi arm64-v8a` for an ARM Android device; +pool Android emulators are x86_64, the default; `--metro ` also opens it +against Metro). Without a cached build for this checkout it builds first, which can +outlast the lease, so run `vp run mobile:install --prepare` (with +the same `--abi`) before you open the device. Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging to this checkout. Open its printed development-client URL with AgentDevice diff --git a/.github/fork/upstream-tag b/.github/fork/upstream-tag index 9690c22eb2d4..5eb2260dec54 100644 --- a/.github/fork/upstream-tag +++ b/.github/fork/upstream-tag @@ -1 +1 @@ -v0.1.10-nightly.20261113.4630 +v0.1.10-nightly.20261114.4669 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79713fe16259..6bfb648fe368 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -591,7 +591,7 @@ jobs: name: Mobile Native Static Analysis needs: changes if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: blacksmith-6vcpu-macos-27 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index a9615439929f..88f96079b897 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -316,6 +316,7 @@ jobs: rust_target: aarch64-apple-darwin resource_key: darwin-arm64 cli_archive: false + untrusted_bundle: true # Release assets download without a GitHub account, unlike workflow # artifacts. All preview DMGs live on one rolling prerelease tagged diff --git a/.github/workflows/mobile-dev-client.yml b/.github/workflows/mobile-dev-client.yml index e204b16f0162..b3e11c1ed6b6 100644 --- a/.github/workflows/mobile-dev-client.yml +++ b/.github/workflows/mobile-dev-client.yml @@ -219,7 +219,7 @@ jobs: ios: name: iOS simulator if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'ios') - runs-on: blacksmith-12vcpu-macos-26 + runs-on: blacksmith-12vcpu-macos-27 timeout-minutes: 60 concurrency: group: mobile-dev-client-ios-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }} diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 39f9d01f6134..cdabb1e4b7ca 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -680,8 +680,8 @@ jobs: echo ":rocket: android: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY" # Off unless the MOBILE_IOS_LOCAL_BUILDS repository variable is `true`; the - # production job only sets ios_build then. Store binaries need the final - # Xcode 27.1; MOBILE_IOS_ALLOW_XCODE_RC=true accepts the release candidate. + # production job only sets ios_build then. Xcode 27.1 matches the EAS build + # image in eas.json. # No sticky disk: it mounts ext4 and only works on Linux runners. ios: name: iOS store build @@ -701,7 +701,6 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} PROFILE: ${{ needs.production.outputs.ios_profile }} BUILD_TAG: mobile-ios-build/${{ needs.production.outputs.ios_profile }}/${{ needs.production.outputs.ios_version }}/${{ needs.production.outputs.ios_fingerprint }} - ALLOW_XCODE_RC: ${{ vars.MOBILE_IOS_ALLOW_XCODE_RC == 'true' }} steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -739,12 +738,9 @@ jobs: if: steps.record.outputs.built != 'true' run: | xcode=/Applications/Xcode_27.1.app - if [ ! -d "$xcode" ] && [ "$ALLOW_XCODE_RC" = "true" ]; then - xcode=/Applications/Xcode_27.1_Release_Candidate.app - fi if [ ! -d "$xcode" ]; then ls -d /Applications/Xcode*.app - echo "::error::Xcode 27.1 (final) is not on this runner. Set the MOBILE_IOS_ALLOW_XCODE_RC repository variable to true to build store binaries with the release candidate." + echo "::error::Xcode 27.1 is not on this runner." exit 1 fi sudo xcode-select -s "$xcode/Contents/Developer" diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 3210ae37d767..402ff8644537 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -45,7 +45,7 @@ jobs: ios: name: iPhone, iPhone Duo, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 + runs-on: blacksmith-12vcpu-macos-27 # Capturing every palette multiplies the device matrix by six, and only the # one native build is shared between them. timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 48f410f0c920..4252cb46e7a0 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -70,6 +70,12 @@ on: required: false default: false type: boolean + # Whether the JS bundle came from a pull request. This job holds the + # signing secrets, so packaging then skips every step that runs the bundle. + untrusted_bundle: + required: false + default: false + type: boolean # A runner of the target's own platform and architecture, for a `runner` # that cannot execute the target. The build job skips everything that # runs the packaged binaries, and a second job runs it there. @@ -134,6 +140,14 @@ jobs: T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ inputs.relay_url }} steps: + # The launch check and the CLI archive smoke test both run the bundle. + - name: Reject running an untrusted bundle + if: inputs.untrusted_bundle && (inputs.launch_check || inputs.cli_archive) + shell: bash + run: | + echo "::error::untrusted_bundle cannot be combined with launch_check or cli_archive, which run the bundle." + exit 1 + # This repository is public, so Git needs no credentials. checkout's # credential cleanup runs submodule foreach even with submodules disabled, # which fails on the orphaned gitlinks in our vendored .repos tree. @@ -573,6 +587,7 @@ jobs: pnpm_config_prefer_offline: "true" T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} + T3CODE_DESKTOP_UNTRUSTED_BUNDLE: ${{ inputs.untrusted_bundle }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 10e0d1cd16c9..aca3b4fc7fe6 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -86,6 +86,7 @@ it.effect.each([false, true])( const historyStarted = yield* Deferred.make(); const releaseHistory = yield* Deferred.make(); const quitting = yield* Ref.make(false); + const tokenRequested = yield* Deferred.make(); const events: string[] = []; const record = (event: string) => Effect.sync(() => { @@ -144,7 +145,7 @@ it.effect.each([false, true])( configure: Effect.void, } as PreviewPasskeys.PreviewPasskeys["Service"]), Effect.provideService(DesktopApplicationMenu.DesktopApplicationMenu, { - configure: Effect.void, + configure: record("menu"), }), Effect.provideService(DesktopUpdates.DesktopUpdates, { configure: Effect.void, @@ -222,7 +223,12 @@ it.effect.each([false, true])( Layer.mock(DesktopClientSettings.DesktopClientSettings, {}), Layer.mock(DesktopConnectionCatalogStore.DesktopConnectionCatalogStore, {}), Layer.mock(DesktopIpc.DesktopIpc, {}), - Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, {}), + Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, { + getBearerToken: record("token").pipe( + Effect.andThen(Deferred.succeed(tokenRequested, undefined)), + Effect.as("token"), + ), + }), Layer.mock(DesktopManagedPolicy.DesktopManagedPolicy, {}), Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, { enabled: true }), Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, {}), @@ -253,7 +259,13 @@ it.effect.each([false, true])( ); yield* Deferred.await(registrationStarted); yield* Deferred.await(windowStarted); - assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]); + yield* Deferred.await(tokenRequested); + // The backend spawns before menus, and its token is minted without waiting for the renderer. + assert.deepEqual( + events.filter((event) => event !== "token"), + ["identity", "ready", "identity", "backend", "menu", "window"], + ); + assert.isAbove(events.indexOf("token"), events.indexOf("backend")); assert.isFalse(yield* Deferred.isDone(registrationStopped)); const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* shutdown.request; diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7ebeaa74a021..0e7f949bc042 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -22,6 +22,7 @@ import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopHomeOwner from "../backend/DesktopHomeOwner.ts"; +import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; @@ -168,13 +169,12 @@ const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( }, ); -const bootstrap = Effect.gen(function* () { +// Spawns the local backend as soon as the protocol, IPC, and legacy storage that every window needs exist. +const startLocalBackend = Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const desktopWindow = yield* DesktopWindow.DesktopWindow; - const snapShot = yield* DesktopSnapShot.DesktopSnapShot; - const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; @@ -193,32 +193,13 @@ const bootstrap = Effect.gen(function* () { yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath, ); - yield* snapShot.initialize; - // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has - // no cached one. Without it, the command is left to Settings. - const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; - yield* Effect.forkScoped( - (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe( - Effect.flatMap((found) => - found || environment.platform === "win32" - ? installCli - : logBootstrapInfo("skipped installing the t3 command without the shell PATH"), - ), - ), - ); - if (!settings.localEnvironmentEnabled) { yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); - if (!(yield* Ref.get(state.quitting))) { - yield* desktopWindow.createMainIfBackendReady; - } - return; + return false; } - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; + const primaryBackend = yield* (yield* DesktopBackendPool.DesktopBackendPool).primary; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); @@ -237,7 +218,8 @@ const bootstrap = Effect.gen(function* () { "This T3 home is unavailable", DesktopHomeOwner.homeOwnedMessage(owner), ); - return yield* (yield* ElectronApp.ElectronApp).quit; + yield* (yield* ElectronApp.ElectronApp).quit; + return false; } const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); @@ -275,29 +257,73 @@ const bootstrap = Effect.gen(function* () { ); } - if (!(yield* Ref.get(state.quitting))) { - if (settings.wslOnly === true && settings.wslBackendEnabled === true) { - yield* desktopWindow.showConnectingSplash; - } - yield* primaryBackend.start; - yield* logBootstrapInfo("bootstrap backend start requested"); - // WSL-only fallback can change the primary endpoint before readiness. - const config = yield* primaryBackend.currentConfig; - if ( - !environment.isDevelopment && - !settings.wslOnly && - Option.isSome(config) && - Option.isNone(config.value.preflightFailure) - ) { - yield* desktopWindow.ensureMain; + if (yield* Ref.get(state.quitting)) { + return false; + } + if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + yield* desktopWindow.showConnectingSplash; + } + yield* primaryBackend.start; + yield* logBootstrapInfo("bootstrap backend start requested"); + // The renderer asks for this token first; minting it at readiness saves a round trip. + yield* Effect.forkScoped( + (yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth).getBearerToken.pipe( + Effect.ignore, + ), + ); + return true; +}).pipe(Effect.withSpan("desktop.startLocalBackend")); + +const bootstrap = Effect.fn("desktop.bootstrap")(function* (backendStarted: boolean) { + const state = yield* DesktopState.DesktopState; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const snapShot = yield* DesktopSnapShot.DesktopSnapShot; + const appActivation = yield* DesktopAppActivation.DesktopAppActivation; + const settings = yield* desktopSettings.get; + if (settings.localEnvironmentEnabled && (!backendStarted || (yield* Ref.get(state.quitting)))) { + return; + } + + yield* snapShot.initialize; + // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has + // no cached one. Without it, the command is left to Settings. + const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; + yield* Effect.forkScoped( + (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe( + Effect.flatMap((found) => + found || environment.platform === "win32" + ? installCli + : logBootstrapInfo("skipped installing the t3 command without the shell PATH"), + ), + ), + ); + + if (!settings.localEnvironmentEnabled) { + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.createMainIfBackendReady; } - yield* appActivation.start.pipe( - Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), - Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), - ); - yield* Effect.forkScoped(wslBackend.reconcile); + return; } -}).pipe(Effect.withSpan("desktop.bootstrap")); + + const primaryBackend = yield* (yield* DesktopBackendPool.DesktopBackendPool).primary; + // WSL-only fallback can change the primary endpoint before readiness. + const config = yield* primaryBackend.currentConfig; + if ( + !environment.isDevelopment && + !settings.wslOnly && + Option.isSome(config) && + Option.isNone(config.value.preflightFailure) + ) { + yield* desktopWindow.ensureMain; + } + yield* appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), + ); + yield* Effect.forkScoped((yield* DesktopWslBackend.DesktopWslBackend).reconcile); +}); const startup = Effect.gen(function* () { const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; @@ -369,14 +395,20 @@ const startup = Effect.gen(function* () { }); } yield* appIdentity.configure; - yield* previewPasskeys.configure; - yield* applicationMenu.configure; + // Sets canInstallUpdates, which the backend bootstrap payload reads. yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* DesktopStartupRecovery.listen; + const backendStarted = yield* startLocalBackend.pipe( + Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)), + ); + yield* previewPasskeys.configure; + yield* applicationMenu.configure; // Portal identity is prepared pre-ready; MIME helpers must not gate the backend or window. yield* linuxUrlHandler.register.pipe(Effect.forkScoped); - yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + yield* bootstrap(backendStarted).pipe( + Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)), + ); }).pipe(Effect.withSpan("desktop.startup")); const scopedProgram = Effect.scoped( diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index d43e7a8388b8..3e8b4967dfde 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -56,7 +56,7 @@ const fakeAppImage = (canMount = true, app = REPORTER) => { `echo $$ >'${root}/mounter.pid'`, canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', // Like the FUSE runtime, any of these unmounts. fd 3 reports to the test when it is open. - `trap 'echo unmounted >&3 2>/dev/null; touch "${root}/unmounted"; exit 0' TERM HUP INT`, + "trap 'echo unmounted >&3 2>/dev/null; exit 0' TERM HUP INT", "trap 'echo mounted >&3 2>/dev/null' WINCH", "while :; do sleep 0.05; done", "", diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index e9c8336feaf4..8e8a2812ab24 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -204,6 +204,35 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); + it.each([ + { from: "T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: true }, + { + from: "T3-Code-0.1.9-nightly.20261110.4449-arm64.AppImage", + to: "T3-Code-Nightly-arm64.AppImage", + restored: true, + }, + { from: "T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-0.1.9-x86_64.AppImage", restored: false }, + { from: "t3code.AppImage", to: "T3-Code-x86_64.AppImage", restored: false }, + { from: "T3-Code-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: false }, + { from: "../T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: false }, + ])("finds the launch path an update from $from to $to removed", ({ from, to, restored }) => { + for (const dir of ["/home/alice/Applications", '/home/al ice/My "100%" $Apps\\x']) { + const previous = dir + "/" + from; + const previousEntry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Beta)", + execTarget: previous, + scheme: "t3code", + }); + assert.equal( + DesktopLinuxUrlHandler.replacedVersionedAppImagePath({ + previousEntry, + appImagePath: dir + "/" + to, + }), + restored ? previous : null, + ); + } + }); + it.effect( "writes the handler entry, refreshes the MIME cache, and claims the scheme default", () => { diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 64535c5b3ac1..35fd46dd7ae5 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -96,6 +96,38 @@ export function renderUrlHandlerDesktopEntry(input: { ].join("\n"); } +function parseUrlHandlerExecTarget(entry: string): string | null { + const exec = /^Exec=(.*) %U$/mu.exec(entry)?.[1]; + if (exec === undefined) return null; + const unescaped = exec.replace(/\\([\\nrt])/gu, (_, char: string) => + char === "n" ? "\n" : char === "r" ? "\r" : char === "t" ? "\t" : char, + ); + const quoted = /^"(.*)"$/su.exec(unescaped)?.[1]; + return ( + quoted === undefined ? unescaped : quoted.replace(/\\([\\`$"])/gu, (_, char: string) => char) + ).replaceAll("%%", "%"); +} + +const APPIMAGE_NAME_VERSION = /\d+\.\d+\.\d+/u; + +// An updater from before version-free AppImage names deletes its own file and installs the update +// beside it under the new name. The previous launch's entry still names the path launchers use. +export function replacedVersionedAppImagePath(input: { + readonly previousEntry: string; + readonly appImagePath: string; +}): string | null { + const previous = parseUrlHandlerExecTarget(input.previousEntry); + if (previous === null) return null; + const previousName = previous.slice(previous.lastIndexOf("/") + 1); + const currentName = input.appImagePath.slice(input.appImagePath.lastIndexOf("/") + 1); + return previous.startsWith("/") && + previous.slice(0, -previousName.length) === input.appImagePath.slice(0, -currentName.length) && + APPIMAGE_NAME_VERSION.test(previousName) && + !APPIMAGE_NAME_VERSION.test(currentName) + ? previous + : null; +} + // Docks may look up the Wayland app id without a desktop entry; icon theme lookups are case-sensitive. const LINUX_THEME_ICON_SIZE = "256x256"; diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 34607e08b844..45887a928abb 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -16,6 +16,7 @@ const { copyFileSyncMock, execFileSyncMock, readFileSyncMock, + symlinkSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -27,6 +28,7 @@ const { copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), readFileSyncMock: vi.fn(), + symlinkSyncMock: vi.fn(), })); vi.mock("electron", () => ({ @@ -51,6 +53,7 @@ vi.mock("node:fs", () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, copyFileSync: copyFileSyncMock, + symlinkSync: symlinkSyncMock, })); vi.mock("node:child_process", () => ({ @@ -72,6 +75,7 @@ describe("DesktopPreReadyPlatform", () => { copyFileSyncMock.mockReset(); execFileSyncMock.mockReset(); readFileSyncMock.mockReset().mockReturnValue("{}"); + symlinkSyncMock.mockReset(); }); it.effect("persists Linux scaling synchronously across relaunches and settings rewrites", () => { @@ -181,6 +185,47 @@ describe("DesktopPreReadyPlatform", () => { ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); + it.effect.each([ + { relaunchedByUpdater: "true", restored: true }, + { relaunchedByUpdater: "", restored: false }, + ])( + "restores the versioned AppImage path an update removed (updater relaunch: $relaunchedByUpdater)", + ({ relaunchedByUpdater, restored }) => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + getSwitchValueMock.mockReturnValue(""); + const entryPath = "/xdg/applications/com.t3tools.T3Code.desktop"; + const files = new Map([[entryPath, "Exec=/Applications/T3-Code-0.1.8-x86_64.AppImage %U\n"]]); + readFileSyncMock.mockImplementation((path: string) => files.get(path) ?? "{}"); + writeFileSyncMock.mockImplementation((path: string, contents: string | Buffer) => { + files.set(path, String(contents)); + }); + + return Effect.scoped( + Effect.gen(function* () { + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + Layer.provide( + DesktopConfig.layerTest({ + XDG_DATA_HOME: "/xdg", + APPIMAGE: "/Applications/T3-Code-x86_64.AppImage", + APPIMAGE_SILENT_INSTALL: relaunchedByUpdater, + }), + ), + ), + ); + assert.deepEqual( + symlinkSyncMock.mock.calls, + restored + ? [["T3-Code-x86_64.AppImage", "/Applications/T3-Code-0.1.8-x86_64.AppImage"]] + : [], + ); + assert.include(files.get(entryPath), "Exec=/Applications/T3-Code-x86_64.AppImage %U"); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }, + ); + it.effect("points the early portal entry at a configured distribution launcher", () => { vi.stubEnv("VITE_DEV_SERVER_URL", ""); getSwitchValueMock.mockReturnValue(""); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index df8059133fc2..8ac4654636ab 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -14,7 +14,10 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; import { trimmedString } from "./DesktopConfig.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; -import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; +import { + renderUrlHandlerDesktopEntry, + replacedVersionedAppImagePath, +} from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; export interface DesktopPreReadyCommandLineReader { @@ -56,6 +59,8 @@ const LinuxDesktopEntryConfig = Config.all({ xdgDataHome: trimmedString("XDG_DATA_HOME"), launcherPath: trimmedString("T3CODE_DESKTOP_LAUNCHER_PATH"), appImagePath: trimmedString("APPIMAGE"), + // electron-updater sets this on the AppImage it relaunches after installing an update. + relaunchedByUpdater: trimmedString("APPIMAGE_SILENT_INSTALL"), }); /** @public */ @@ -116,8 +121,26 @@ export const make = Effect.gen(function* () { ); } catch {} } + const desktopEntryPath = NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName); + const { appImagePath } = linuxDesktopEntryConfig; + if ( + Option.isSome(appImagePath) && + Option.isNone(linuxDesktopEntryConfig.launcherPath) && + Option.contains(linuxDesktopEntryConfig.relaunchedByUpdater, "true") + ) { + try { + const replacedPath = replacedVersionedAppImagePath({ + previousEntry: NodeFS.readFileSync(desktopEntryPath, "utf8"), + appImagePath: appImagePath.value, + }); + // Relative, so the pair survives a moved folder; symlink never overwrites a file. + if (replacedPath !== null) { + NodeFS.symlinkSync(NodePath.posix.basename(appImagePath.value), replacedPath); + } + } catch {} + } NodeFS.writeFileSync( - NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), + desktopEntryPath, renderUrlHandlerDesktopEntry({ displayName: resolveDesktopAppBranding({ isDevelopment: linux.isDevelopment, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 17ba6a674f76..29dbbf0abcc2 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -683,6 +683,43 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("gives the Windows and WSL backends one host scope that survives a restart", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const launch = Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const primary = yield* configuration.resolvePrimary; + const wsl = yield* configuration.resolveWsl({ port: 5050, distro: null }); + assert.equal(wsl.env.T3CODE_DESKTOP_HOST_SCOPE, primary.env.T3CODE_DESKTOP_HOST_SCOPE); + assert.include((wsl.env.WSLENV ?? "").split(":"), "T3CODE_DESKTOP_HOST_SCOPE"); + return primary.env.T3CODE_DESKTOP_HOST_SCOPE; + }).pipe( + Effect.provide( + layerBackendConfiguration().pipe( + Layer.provideMerge(layerServerExposure), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), + ), + ), + ); + + const first = yield* launch; + assert.match(first ?? "", /^[0-9a-f]{32}$/); + assert.equal(yield* launch, first); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -748,7 +785,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); assert.equal( config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:T3CODE_DESKTOP_HOST_SCOPE:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", ); }).pipe( Effect.provide( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index e7b8405f8124..42c6d0db6e22 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -243,8 +243,38 @@ interface SharedBootstrapInput { readonly bootstrapToken: string; readonly bootstrapSecret: string; readonly observabilitySettings: BackendObservabilitySettings; + readonly hostScope: string; } +// Matches the server's DESKTOP_HOST_SCOPE_ENV. +const DESKTOP_HOST_SCOPE_ENV = "T3CODE_DESKTOP_HOST_SCOPE"; +const HOST_SCOPE_FILE_NAME = "desktop-host-scope"; +const HOST_SCOPE_PATTERN = /^[0-9a-f]{32}$/; + +/** + * Not a credential: it only tells this install's Windows and WSL servers that + * they run on one machine, so usage both read from one Windows directory counts + * once. Persisted so summaries from earlier launches still match. + */ +const readOrCreateHostScope = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const random = yield* SecureRandom.SecureRandom; + const scopePath = environment.path.join(environment.stateDir, HOST_SCOPE_FILE_NAME); + const existing = (yield* fileSystem + .readFileString(scopePath) + .pipe(Effect.orElseSucceed(() => ""))).trim(); + if (HOST_SCOPE_PATTERN.test(existing)) return existing; + const scope = Hex.encode(yield* random.randomBytes(16).pipe(Effect.orDie)); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }).pipe( + Effect.andThen(fileSystem.writeFileString(scopePath, scope)), + Effect.catch((cause) => + Effect.logWarning("Could not persist the desktop host scope.", { cause }), + ), + ); + return scope; +}); + type WslPreflightRuntime = | { readonly kind: "executable"; @@ -540,6 +570,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv ELECTRON_RUN_AS_NODE: "1", T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1", T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), + [DESKTOP_HOST_SCOPE_ENV]: input.hostScope, }, extendEnv: true, bootstrap, @@ -636,8 +667,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`); const distroArgs = distroForConfig ? ["-d", distroForConfig] : []; - const forwardedEnv: Record = {}; - const forwardedEnvNames: string[] = []; + const forwardedEnv: Record = { [DESKTOP_HOST_SCOPE_ENV]: input.hostScope }; + const forwardedEnvNames: string[] = [DESKTOP_HOST_SCOPE_ENV]; for (const name of WSL_FORWARDED_ENV_NAMES) { const value = env[name]; if (value !== undefined && value.length > 0) { @@ -735,6 +766,13 @@ export const make = Effect.gen(function* () { const random = yield* SecureRandom.SecureRandom; // Random generation yields; serialize it so concurrent backends cannot capture distinct bootstrap secrets. const secretRef = yield* SynchronizedRef.make(Option.none()); + const hostScope = yield* Effect.cached( + readOrCreateHostScope.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(SecureRandom.SecureRandom, random), + ), + ); const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { onSome: (secret) => Effect.succeed([secret, current] as const), @@ -762,6 +800,7 @@ export const make = Effect.gen(function* () { bootstrapToken, bootstrapSecret, observabilitySettings, + hostScope: yield* hostScope, } satisfies SharedBootstrapInput; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 8a8389082ce8..06a1f768f4bd 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -378,6 +378,42 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("becomes ready from the server ready line while HTTP probes stall", () => + Effect.scoped( + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + const exit = yield* Deferred.make(); + const readyLine = encodeDesktopTelemetryControl({ version: 1, type: "serverReady" }); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + getOutputFd: (fd) => + fd === 5 + ? Stream.fromEffect(Deferred.await(probeStarted)).pipe( + Stream.flatMap(() => Stream.encodeText(Stream.make(`${readyLine}\n`))), + ) + : Stream.empty, + exitCode: Deferred.await(exit), + kill: () => + Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), + }), + ), + ), + ), + httpClientLayer: layerHttpClient(() => + Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), + ), + }); + + yield* instance.start; + assert.isTrue(yield* instance.waitForReady(Duration.seconds(15))); + }), + ), + ); + it.effect("continues routing desktop telemetry control messages after an invalid line", () => Effect.gen(function* () { const handled = yield* Deferred.make(); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 89ebc9e862ec..4acaa8a3c18b 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,5 +1,6 @@ import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -484,21 +485,23 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ), ); const outputFibers: Array> = []; + const serverReady = yield* Deferred.make(); yield* options.onStarted?.(handle.pid) ?? Effect.void; - if ( - options.bootstrap.desktopTelemetryControlFd !== undefined && - options.onDesktopTelemetryControl !== undefined - ) { + if (options.bootstrap.desktopTelemetryControlFd !== undefined) { const controlFd = options.bootstrap.desktopTelemetryControlFd; - const handleControl = options.onDesktopTelemetryControl; + const handleControl = options.onDesktopTelemetryControl ?? (() => Effect.void); yield* handle.getOutputFd(controlFd).pipe( Stream.decodeText(), Stream.splitLines, Stream.filter((line) => line.trim().length > 0), Stream.runForEach((line) => decodeDesktopTelemetryControlLine(line).pipe( - Effect.flatMap(handleControl), + Effect.flatMap((message) => + message.type === "serverReady" + ? Deferred.succeed(serverReady, undefined) + : handleControl(message), + ), Effect.catchCause((cause) => logBackendProcessWarning("ignored invalid desktop telemetry control message", { fd: controlFd, @@ -570,14 +573,18 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ); } // Cold WSL boots across /mnt/c can exceed one probe budget; retry while the backend is alive. + // HTTP polling stays as the fallback for backends without the control fd, such as WSL. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => - waitForHttpReady({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( + Effect.raceFirst( + waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }), + Deferred.await(serverReady), + ).pipe( Effect.flatMap(() => options.onReady?.(handle.pid) ?? Effect.void), Effect.as(true), Effect.catchTags({ diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 80053c78caee..0e422f9bc735 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -88,6 +88,7 @@ export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webvie export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; +export const PREVIEW_STOP_LOADING_CHANNEL = "desktop:preview-stop-loading"; export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh"; export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index ea291b66b314..cc810fee072a 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -168,6 +168,11 @@ const refresh = tabMethod( "desktop.ipc.preview.refresh", (manager, tabId) => manager.refresh(tabId), ); +const stopLoading = tabMethod( + IpcChannels.PREVIEW_STOP_LOADING_CHANNEL, + "desktop.ipc.preview.stopLoading", + (manager, tabId) => manager.stopLoading(tabId), +); const zoomIn = tabMethod( IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, "desktop.ipc.preview.zoomIn", @@ -472,6 +477,7 @@ export const methods = [ goBack, goForward, refresh, + stopLoading, zoomIn, zoomOut, resetZoom, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f241e14587f3..003009f7190f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,7 +12,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; // @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime. import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -125,6 +127,15 @@ const layerDesktopSshEnvironment = Layer.unwrap( const environment = yield* DesktopEnvironment.DesktopEnvironment; return DesktopSshEnvironment.layer({ resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), + resolveLauncherId: yield* Effect.cached( + DesktopSshEnvironment.readOrCreateLauncherId( + environment.path.join(environment.stateDir, "ssh-launcher-id"), + ).pipe( + Effect.provide( + yield* Effect.context(), + ), + ), + ), }); }), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a538b821ff77..6414743ac3b2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -389,6 +389,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }), goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }), refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }), + stopLoading: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_STOP_LOADING_CHANNEL, { tabId }), zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }), zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 393e4cccccf9..d6f25d66aa81 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1298,6 +1298,41 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("stops crediting the agent once a person acts through a viewer", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const viewer = (id: number, method: string, type: string) => + raw({ id, method, params: { type }, sessionId: "t3-preview-page-1" }); + const led = () => host.agentLedPage(debuggee.tab.webContents); + + yield* command(host, 1, "DOM.getDocument"); + expect(led()).toBe(false); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + + for (const [method, type] of [ + ["Input.dispatchKeyEvent", "rawKeyDown"], + ["Input.dispatchMouseEvent", "mousePressed"], + ["Input.dispatchTouchEvent", "touchStart"], + ] as const) { + yield* command(host, 4, "Runtime.evaluate"); + expect(led()).toBe(true); + // The viewer's pointer only passes over the page the agent drives. + yield* viewer(5, "Input.dispatchMouseEvent", "mouseMoved"); + yield* viewer(6, "Input.dispatchMouseEvent", "mouseWheel"); + expect(led()).toBe(true); + yield* viewer(7, method, type); + expect(led()).toBe(false); + } + }).pipe(Effect.scoped), + ); + it.effect("detaches only the tab whose CDP event is too large to relay", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index e5f43634d204..296be2fa6709 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -26,7 +26,9 @@ const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; /** The same commands as above, by method, for the relay's sends. */ const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/; -const AGENT_DOWNLOAD_WINDOW_MS = 5_000; +const AGENT_INPUT_WINDOW_MS = 5_000; +/** A viewer's pointer travelling over the stream activates nothing on the page. */ +const POINTER_TRAVEL = new Set(["mouseMoved", "mouseWheel"]); const FOCUS_IDLE = "5 seconds"; /** Well inside the server's 10 s DESKTOP_ATTACH_TIMEOUT, for a first page that never reports it is up. */ const FIRST_PAGE_TIMEOUT = "5 seconds"; @@ -60,6 +62,8 @@ interface AttachedTab { downloadDirectory: string | null; pendingDownloadGuid: string | null; agentInputAt: number; + /** When the agent last drove the page with no person's input through a viewer since. */ + agentLedAt: number; focusEmulated: boolean; focusChange: Promise | null; focusUses: number; @@ -98,6 +102,8 @@ export class DesktopBrowserHost extends Context.Service< readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void; readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; + /** Whether what the page does now follows the agent's input, not a viewer's. */ + readonly agentLedPage: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; readonly pointers: Stream.Stream<{ readonly key: DesktopBrowserTabKey; @@ -351,11 +357,13 @@ export const make = Effect.gen(function* () { if (tab.screencasts.size > 0) return Promise.resolve({}); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. - if ( - AGENT_INPUT_METHOD.test(method) && - (owner === undefined || !streamingOwners.has(owner)) - ) { - tab.agentUsed = true; + if (AGENT_INPUT_METHOD.test(method)) { + if (owner === undefined || !streamingOwners.has(owner)) { + tab.agentUsed = true; + tab.agentLedAt = performance.now(); + } else if (method.startsWith("Input.") && !POINTER_TRAVEL.has(params["type"])) { + tab.agentLedAt = Number.NEGATIVE_INFINITY; + } } // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. const forward = (current?: () => boolean) => { @@ -490,6 +498,7 @@ export const make = Effect.gen(function* () { downloadDirectory: null, pendingDownloadGuid: null, agentInputAt: Number.NEGATIVE_INFINITY, + agentLedAt: Number.NEGATIVE_INFINITY, focusEmulated: false, focusChange: null, focusUses: 0, @@ -604,7 +613,12 @@ export const make = Effect.gen(function* () { const humanStartedDownload = (source: Electron.WebContents) => { const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source); - return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; + return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_INPUT_WINDOW_MS; + }; + + const agentLedPage = (source: Electron.WebContents) => { + const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source); + return tab !== undefined && performance.now() - tab.agentLedAt <= AGENT_INPUT_WINDOW_MS; }; const announceAll = Effect.suspend(() => { @@ -647,6 +661,7 @@ export const make = Effect.gen(function* () { setShown, placeDownload, humanStartedDownload, + agentLedPage, }); }); diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index a0889940a668..48191ce2c4b9 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -44,6 +44,30 @@ describe("preview guest input", () => { expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything()); }); + it("stops the guest navigation without stopping its embedder", async () => { + let guestLoading = true; + let hostLoading = true; + const guest = { + stop: () => { + guestLoading = false; + }, + }; + const debuggee = { + sendCommand: async () => { + hostLoading = false; + return {}; + }, + }; + await sendPageCommand( + guest as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + "Page.stopLoading", + {}, + ); + expect(guestLoading).toBe(false); + expect(hostLoading).toBe(true); + }); + it.each([ { sessionId: undefined, diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 2af13af8d7da..4d794beea62b 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -185,6 +185,10 @@ export const sendPageCommand = async ( else webContents.reload(); return {}; } + if (method === "Page.stopLoading") { + webContents.stop(); + return {}; + } if (!FOCUS_ROUTED_INPUT.has(method) || webContents.focusedFrame) { return debuggee.sendCommand(method, params); } diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 0a9f4cb85c00..1d24079d1947 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -5,7 +5,6 @@ export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; -export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; // Sent to the hosting element (ipc-message). The web app mirrors this // literal in HostedBrowserWebview because it cannot import from the desktop app. export const POINTER_DOWN_HOST_CHANNEL = "preview:pointer-down"; @@ -15,6 +14,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; +export const THUMB_PRESS_CHANNEL = "preview:thumb-press"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index b344b1ac93b2..802cd315b8e6 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -619,6 +619,7 @@ describe("PreviewManager", () => { setShown: () => {}, loadFailed: () => {}, humanStartedDownload: () => true, + agentLedPage: () => false, placeDownload: () => true, }), Layer.mock(PreviewPasskeys.PreviewPasskeys)({ @@ -745,6 +746,137 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + "navigate", + "reload", + "goBack", + "goForward", + "refresh", + "hardReload", + ] as const)("%s stops a page script that never yields before it navigates", (operation) => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + // As in Chromium: the hung renderer answers nothing, and a terminate reaches it only + // while no navigation is pending. + let stopped = false; + const probed = yield* Deferred.make(); + const navigatedAfterStop: Array = []; + const navigated = () => { + navigatedAfterStop.push(stopped); + }; + const sendCommand = async (method: string) => { + if (method === "Runtime.evaluate") { + Deferred.doneUnsafe(probed, Effect.void); + await new Promise(() => {}); + } + if (method === "Runtime.terminateExecution") stopped = true; + }; + Object.assign(preview.webContents, { + getURL: () => "http://localhost:5173/busy", + reload: navigated, + reloadIgnoringCache: navigated, + loadURL: async () => navigated(), + navigationHistory: { + canGoBack: () => true, + canGoForward: () => true, + goBack: navigated, + goForward: navigated, + }, + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_busy"); + yield* manager.registerWebview("tab_busy", 42); + + const leaving = yield* ( + operation === "navigate" + ? manager.navigate("tab_busy", "http://localhost:5173/next") + : operation === "reload" + ? manager.navigate("tab_busy", "http://localhost:5173/busy") + : manager[operation]("tab_busy") + ).pipe(Effect.forkChild); + const probing = yield* Effect.raceFirst( + Deferred.await(probed).pipe(Effect.as(true)), + Fiber.await(leaving).pipe(Effect.as(false)), + ); + if (probing) yield* TestClock.adjust(1_000); + yield* Fiber.join(leaving); + + expect(navigatedAfterStop).toEqual([true]); + }), + ), + ); + + effectIt.effect("keeps mouse back and forward buttons inside the preview tab", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const goBack = vi.fn(); + const goForward = vi.fn(); + const send = vi.fn(); + let canGoBack = false; + Object.assign(preview.webContents, { + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + getZoomFactor: () => 2, + send, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_mouse_navigation"); + yield* manager.registerWebview("tab_mouse_navigation", 42); + + // Electron reports thumb buttons as "back"/"forward" for every frame of the guest, iframes included. + const beforeMouse = preview.listeners.get("before-mouse-event"); + expect(beforeMouse, "guest has no before-mouse-event listener").toBeDefined(); + const press = (type: "mouseDown" | "mouseUp", button: string) => { + const preventDefault = vi.fn(); + beforeMouse!({ preventDefault } as never, { type, button, x: 10, y: 10 } as never); + return preventDefault; + }; + + // At the start of tab history the press is still swallowed, or the host app goes back instead. + expect(press("mouseDown", "back")).toHaveBeenCalledOnce(); + expect(press("mouseUp", "back")).toHaveBeenCalledOnce(); + expect(goBack).not.toHaveBeenCalled(); + + canGoBack = true; + expect(press("mouseDown", "back")).toHaveBeenCalledOnce(); + expect(goBack).not.toHaveBeenCalled(); + expect(press("mouseUp", "back")).toHaveBeenCalledOnce(); + expect(goBack).toHaveBeenCalledOnce(); + expect(press("mouseDown", "forward")).toHaveBeenCalledOnce(); + expect(press("mouseUp", "forward")).toHaveBeenCalledOnce(); + expect(goForward).toHaveBeenCalledOnce(); + for (const button of ["left", "right"]) { + expect(press("mouseDown", button)).not.toHaveBeenCalled(); + expect(press("mouseUp", button)).not.toHaveBeenCalled(); + } + expect(goBack).toHaveBeenCalledOnce(); + expect(goForward).toHaveBeenCalledOnce(); + // The guest replays dropped presses for human input and recordings, in CSS px. + expect(send.mock.calls.filter(([channel]) => channel === "preview:thumb-press")).toEqual([ + ["preview:thumb-press", "down", 5, 5, 3], + ["preview:thumb-press", "up", 5, 5, 3], + ["preview:thumb-press", "down", 5, 5, 3], + ["preview:thumb-press", "up", 5, 5, 3], + ["preview:thumb-press", "down", 5, 5, 4], + ["preview:thumb-press", "up", 5, 5, 4], + ]); + }), + ), + ); + effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => withManager((manager) => Effect.gen(function* () { @@ -2133,6 +2265,71 @@ describe("PreviewManager", () => { }).pipe(Effect.provide(managerLayer())), ); + effectIt.effect( + "opens a new-tab link in the background only when the agent's input led to it", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => { + throw new Error("unused"); + }, 42), + { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + focusedFrame: null, + }, + ); + fromId.mockReturnValue(contents); + const manager = yield* PreviewManager.PreviewManager; + const openLinks = yield* Queue.unbounded(); + yield* manager.subscribeOpenLinks(({ background }) => + Queue.offer(openLinks, background).pipe(Effect.asVoid), + ); + const attached = yield* Deferred.make(); + yield* host.events.pipe( + Stream.runForEach((line) => + new TextDecoder().decode(line).includes('"type":"attached"') + ? Deferred.succeed(attached, undefined) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.registerWebview("tab_1", 42); + yield* Deferred.await(attached); + const openWindow = vi.mocked(contents.setWindowOpenHandler).mock.calls[0]![0]; + const openBlankLink = Effect.suspend(() => { + openWindow({ + url: "https://example.com/next", + disposition: "foreground-tab", + } as Electron.HandlerDetails); + return Queue.take(openLinks); + }); + + expect(yield* openBlankLink).toBe(false); + + yield* host.handleCommandLine( + JSON.stringify({ + type: "cdp", + ...serverTab, + message: JSON.stringify({ + id: 1, + method: "Runtime.evaluate", + params: { expression: "document.querySelector('a').click()" }, + sessionId: "t3-preview-page", + }), + }), + ); + expect(yield* openBlankLink).toBe(true); + + // A person clicking in the guest itself gives it a focused frame. + Object.assign(contents, { focusedFrame: {} }); + expect(yield* openBlankLink).toBe(false); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), + ); + effectIt.effect("reports early and subsequent load failures for the current guest", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.DesktopBrowserHost; diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 686248bf8892..eba18ab631dd 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -81,13 +81,13 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, - MOUSE_NAVIGATE_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, RECORDING_POINTER_CHANNEL, START_PICK_CHANNEL, + THUMB_PRESS_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; @@ -136,6 +136,7 @@ const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; +const BUSY_PAGE_PROBE_MS = 1_000; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; const PICTURE_IN_PICTURE_MIN_WIDTH = 240; @@ -1490,15 +1491,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; - const mouseNavigate = (_event: unknown, payload?: unknown): void => { - const direction = - typeof payload === "object" && payload !== null && "direction" in payload - ? (payload as { direction?: unknown }).direction - : undefined; - if (direction !== "back" && direction !== "forward") return; + // Chromium navigates the outermost WebContents (the host app) on thumb buttons, even from guest iframes. + const beforeMouse = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + // Electron reports thumb buttons as "back" and "forward", which its typings omit. + const button: string | undefined = mouse.button; + if (button !== "back" && button !== "forward") return; + if (mouse.type !== "mouseDown" && mouse.type !== "mouseUp") return; + event.preventDefault(); + // The page never sees the press, so the guest replays it; these coordinates are DIPs, not CSS px. + const zoom = wc.getZoomFactor(); + const phase = mouse.type === "mouseDown" ? "down" : "up"; + wc.send( + THUMB_PRESS_CHANNEL, + phase, + mouse.x / zoom, + mouse.y / zoom, + button === "back" ? 3 : 4, + ); + if (phase !== "up") return; runFork( attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { - if (direction === "back") { + if (button === "back") { if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); } else if (wc.navigationHistory.canGoForward()) { wc.navigationHistory.goForward(); @@ -1608,9 +1621,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); + wc.off("before-mouse-event", beforeMouse); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); - wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), @@ -1631,7 +1644,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); - wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { @@ -1645,7 +1657,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function emitOpenLink({ tabId, url: details.url, - background: details.disposition === "background-tab", + // A person's own click or key leaves the guest a focused frame; GuestInput hands + // focus back after the agent's press, so a foreground tab would take it again. + background: + details.disposition === "background-tab" || + (wc.focusedFrame === null && browserHost.agentLedPage(wc)), }), ); return { action: "deny" }; @@ -1659,6 +1675,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); wc.on("did-create-window", windowCreated); wc.on("before-input-event", beforeInput); + wc.on("before-mouse-event", beforeMouse); }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { @@ -2022,6 +2039,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + /** + * A same-site navigation reuses the renderer, so it never commits while a page script holds it. + * Runs before the navigation starts: Chromium does not deliver a terminate once one is pending. + */ + const leaveBusyPage = Effect.fn("PreviewManager.leaveBusyPage")(function* ( + wc: Electron.WebContents, + ) { + const control = (yield* SynchronizedRef.get(controlSessionsRef)).get(wc.id); + if (!control) return; + const answered = yield* Effect.promise(() => + control.debugger.sendCommand("Runtime.evaluate", { expression: "0" }).then( + () => undefined, + () => undefined, + ), + ).pipe(Effect.timeoutOption(BUSY_PAGE_PROBE_MS)); + if (Option.isSome(answered)) return; + // Not awaited: a renderer blocked on something other than script may never acknowledge it. + yield* Effect.sync(() => { + void control.debugger.sendCommand("Runtime.terminateExecution").catch(() => undefined); + }); + }); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), @@ -2104,6 +2143,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } + yield* leaveBusyPage(wc); if (wc.getURL() === url) { yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => wc.reload(), @@ -2139,17 +2179,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); }); + const leavePage = Effect.fn("PreviewManager.leavePage")(function* ( + operation: string, + tabId: string, + use: (wc: Electron.WebContents) => void, + ) { + const wc = yield* requireWebContents(tabId); + yield* leaveBusyPage(wc); + yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); + }); + const goBack = (tabId: string) => - withWebContents("goBack", tabId, (wc) => { + leavePage("goBack", tabId, (wc) => { if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); }); const goForward = (tabId: string) => - withWebContents("goForward", tabId, (wc) => { + leavePage("goForward", tabId, (wc) => { if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); }); - const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); + const refresh = (tabId: string) => leavePage("refresh", tabId, (wc) => wc.reload()); + const stopLoading = (tabId: string) => withWebContents("stopLoading", tabId, (wc) => wc.stop()); const hardReload = (tabId: string) => - withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + leavePage("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); const findInPage = ( tabId: string, text: string, @@ -3531,6 +3582,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function prepareWebview, reapplyZoom, refresh, + stopLoading, registerWebview, resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), setZoomFactor: (tabId: string, zoomFactor: number) => @@ -3732,6 +3784,7 @@ export class PreviewManager extends Context.Service< readonly goBack: (tabId: string) => Effect.Effect; readonly goForward: (tabId: string) => Effect.Effect; readonly refresh: (tabId: string) => Effect.Effect; + readonly stopLoading: (tabId: string) => Effect.Effect; readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; @@ -3906,6 +3959,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { goBack: operations.goBack, goForward: operations.goForward, refresh: operations.refresh, + stopLoading: operations.stopLoading, zoomIn: operations.zoomIn, zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index cbaf873e4933..42867b9d9cb4 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -39,7 +39,6 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, - MOUSE_NAVIGATE_CHANNEL, POINTER_DOWN_HOST_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, @@ -48,6 +47,7 @@ import { RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, + THUMB_PRESS_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const INPUT_LAYER_ATTRIBUTE = "data-t3code-annotation-input"; @@ -249,34 +249,28 @@ const isolateOverlayKey = (event: KeyboardEvent): void => { for (const type of ["keydown", "keypress", "keyup"] as const) window.addEventListener(type, isolateOverlayKey, true); -const MOUSE_BUTTON_BACK = 3; -const MOUSE_BUTTON_FORWARD = 4; - -const navigationDirectionForButton = (button: number): "back" | "forward" | null => { - if (button === MOUSE_BUTTON_BACK) return "back"; - if (button === MOUSE_BUTTON_FORWARD) return "forward"; - return null; -}; - -// Chromium routes thumb-button navigation to the focused WebContents; navigate this hovered guest explicitly. -const suppressNavigationButton = (event: MouseEvent): void => { - if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; - event.preventDefault(); - event.stopImmediatePropagation(); -}; - -const requestNavigationForButton = (event: MouseEvent): void => { - if (!event.isTrusted) return; - const direction = navigationDirectionForButton(event.button); - if (direction === null) return; - event.preventDefault(); - event.stopImmediatePropagation(); - ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); -}; - -window.addEventListener("mousedown", suppressNavigationButton, true); -window.addEventListener("mouseup", requestNavigationForButton, true); -window.addEventListener("auxclick", suppressNavigationButton, true); +let replayedThumbPress: { readonly x: number; readonly y: number } | null = null; +ipcRenderer.on( + THUMB_PRESS_CHANNEL, + (_event, phase: unknown, x: unknown, y: unknown, button: unknown) => { + if (phase !== "down" && phase !== "up") return; + if (typeof x !== "number" || typeof y !== "number" || typeof button !== "number") return; + // Main reports presses inside a frame in that frame's coordinates. + const inFrame = document.querySelector("iframe:hover, frame:hover, object:hover, embed:hover"); + if (phase === "up") { + const pressed = replayedThumbPress; + replayedThumbPress = null; + // A replayed press is always released, at its own point if the release lands in a frame. + if (pressed) recordingCursor?.press(inFrame ? pressed : { x, y }, "up"); + return; + } + if (inFrame) return; + replayedThumbPress = { x, y }; + ipcRenderer.sendToHost(POINTER_DOWN_HOST_CHANNEL); + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { kind: "pointer", x, y, button }); + recordingCursor?.press({ x, y }, "down"); + }, +); const nextId = createAnnotationIdSource(); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 29f1865de291..ab74c361b68e 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -156,13 +156,15 @@ export function installRecordingCursor( moveHuman(point); pointer(point, "move"); }; + const press = (point: { readonly x: number; readonly y: number }, phase: "down" | "up") => { + if (phase === "down") moveHuman(point); + pointer(point, phase); + }; const pointerDown = (event: PointerEvent) => { - if (event.pointerType === "touch") return; - moveHuman({ x: event.clientX, y: event.clientY }); - pointer({ x: event.clientX, y: event.clientY }, "down"); + if (event.pointerType !== "touch") press({ x: event.clientX, y: event.clientY }, "down"); }; const pointerUp = (event: PointerEvent) => { - if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up"); + if (event.pointerType !== "touch") press({ x: event.clientX, y: event.clientY }, "up"); }; const keyDown = (event: KeyboardEvent) => { if (event.isComposing || event.repeat) return; @@ -196,6 +198,7 @@ export function installRecordingCursor( window.addEventListener("blur", hide); return { move, + press, keyPress, setController, setTheme, diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 1d28ed186238..04406f63c6b3 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -240,6 +240,28 @@ describe("DesktopSettings", () => { ), ); + it.effect("keeps Local environment off when another value in the file is unreadable", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ "localEnvironmentEnabled": false, "serverExposureMode": "tailnet" }\n`, + ); + + assert.isFalse((yield* settings.load).localEnvironmentEnabled); + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 800, height: 600 }, false); + assert.include( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + `"localEnvironmentEnabled":false`, + ); + }), + ), + ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1bb8a2521d88..e81da7066064 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -90,20 +90,25 @@ const DesktopWindowBoundsDocument = Schema.Struct({ height: Schema.Number, }); +// A value this build cannot read drops only its own key, so the rest of the file +// still loads and is written back instead of every setting resetting to defaults. +const optionalReadableKey = (schema: S) => + Schema.optionalKey(schema.pipe(Schema.catchDecoding(() => Effect.succeedNone))); + const DesktopSettingsDocument = Schema.Struct({ - localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), - linuxPasswordStore: Schema.optionalKey(Schema.Unknown), - mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), - mainWindowMaximized: Schema.optionalKey(Schema.Boolean), - serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), - tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), - tailscaleServePort: Schema.optionalKey(Schema.Number), - updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), - updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), - wslBackendEnabled: Schema.optionalKey(Schema.Boolean), - wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), - wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), - wslOnly: Schema.optionalKey(Schema.Boolean), + localEnvironmentEnabled: optionalReadableKey(Schema.Boolean), + linuxPasswordStore: optionalReadableKey(Schema.Unknown), + mainWindowBounds: optionalReadableKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: optionalReadableKey(Schema.Boolean), + serverExposureMode: optionalReadableKey(DesktopServerExposureModeSchema), + tailscaleServeEnabled: optionalReadableKey(Schema.Boolean), + tailscaleServePort: optionalReadableKey(Schema.Number), + updateChannel: optionalReadableKey(DesktopUpdateChannelSchema), + updateChannelConfiguredByUser: optionalReadableKey(Schema.Boolean), + wslBackendEnabled: optionalReadableKey(Schema.Boolean), + wslMode: optionalReadableKey(Schema.Literals(["local", "wsl"])), + wslDistro: optionalReadableKey(Schema.NullOr(Schema.String)), + wslOnly: optionalReadableKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index d47984e2bcb5..0693c16cc15f 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -19,6 +19,7 @@ import { import * as SshTunnel from "@t3tools/ssh/tunnel"; import * as Context from "effect/Context"; import type * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -70,8 +71,30 @@ export class DesktopSshEnvironment extends Context.Service< export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliRunner?: Effect.Effect; + readonly resolveLauncherId?: Effect.Effect; } +// Remote servers record this ID as their owner, so it has to outlive restarts, +// updates and edits to a saved connection. Without a stored one there is none. +export const readOrCreateLauncherId = Effect.fn("desktop.ssh.readOrCreateLauncherId")( + function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + const random = yield* SecureRandom.SecureRandom; + // Any other read failure may hide a stored ID, which a new one would overwrite. + const stored = yield* fileSystem.readFileString(filePath).pipe( + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed(""), + ), + ); + if (/^[0-9a-f]{32}$/u.test(stored.trim())) return stored.trim(); + const launcherId = (yield* random.uuidV4()).replace(/-/g, ""); + yield* fileSystem.writeFileString(filePath, `${launcherId}\n`); + return launcherId; + }, + Effect.orElseSucceed(() => null), +); + function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { return discoverSshHosts(input ?? {}); } @@ -164,10 +187,13 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshTunnel.SshEnvironmentManager.layer( - options.resolveCliRunner === undefined + SshTunnel.SshEnvironmentManager.layer({ + ...(options.resolveCliRunner === undefined ? {} - : { resolveCliRunner: options.resolveCliRunner }, - ), + : { resolveCliRunner: options.resolveCliRunner }), + ...(options.resolveLauncherId === undefined + ? {} + : { resolveLauncherId: options.resolveLauncherId }), + }), ), ); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 0a2b10e08c5a..415901878caf 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -352,6 +352,8 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { return Queue.offer(updateCommitQueue, message).pipe(Effect.asVoid); case "cancelDesktopUpdate": return Queue.offer(updateCancellationQueue, message).pipe(Effect.asVoid); + case "serverReady": + return Effect.void; } }; const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = ( diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 69962f52e4f1..e2fd195b1955 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -1,16 +1,22 @@ // @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell. +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; import { ManagedPolicyFileError } from "@t3tools/shared/managedPolicyFile"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import { + DesktopWslEnvironment, + layer as layerDesktopWslEnvironment, buildWslRuntimeInstallScript, makeIsAvailable, buildWslRuntimeInvalidateScript, @@ -380,6 +386,93 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0); }); + // Runs the distro side on the local bash, so a Windows dev box using the wsl.exe runner skips it. + describe.skipIf(posixShellRunner?.file !== "bash")("prepareRuntime through wsl.exe", () => { + // Emulates `wsl.exe [-d ] [--cd ] [--|--exec] ` in a distro with automount off. + const layerFakeWslExe = (home: string) => + Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.map(ChildProcessSpawner.ChildProcessSpawner, (local) => + ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== "wsl.exe") { + return local.spawn(command); + } + const args = command.args.map((word) => + Redacted.isRedacted(word) ? Redacted.value(word) : word, + ); + while (args.length > 0) { + const flag = args[0]; + if (flag === "-d" || flag === "--distribution" || flag === "--cd") { + args.splice(0, 2); + } else if (flag === "--" || flag === "--exec" || flag === "-e") { + args.shift(); + break; + } else { + break; + } + } + const [file = "sh", ...rest] = + args[0] === "wslpath" + ? [ + "sh", + "-c", + 'printf "wslpath: %s\\n" "$1" >&2; exit 1', + "wslpath", + args.at(-1) ?? "", + ] + : args; + return local.spawn( + ChildProcess.make(file, rest, { + ...command.options, + env: { ...command.options.env, HOME: home }, + extendEnv: true, + }), + ); + }), + ), + ); + + const layerWslEnvironment = (home: string) => + layerDesktopWslEnvironment.pipe( + Layer.provide( + Layer.mergeAll( + layerFakeWslExe(home), + DesktopManagedPolicy.layerTest(), + Layer.effect( + DesktopEnvironment.DesktopEnvironment, + Effect.map( + Path.Path, + (path) => + ({ platform: "win32", path }) as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + ), + ), + Layer.provide(NodeServices.layer), + ); + + it.live("installs the runtime when wslpath cannot translate the archive's Windows path", () => { + const fixture = createFixture(); + return Effect.gen(function* () { + const wsl = yield* DesktopWslEnvironment; + // The archive's Windows path; with no drives mounted, wslpath cannot map it into the distro. + const translated = yield* wsl.windowsToWslPath("Ubuntu", fixture.archivePath); + expect(translated._tag).toBe("None"); + + const prepared = yield* wsl.prepareRuntime("Ubuntu", { + windowsPath: fixture.archivePath, + runtimeId: fixture.runtimeId, + sha256: fixture.archiveSha, + }); + + expect(prepared).toEqual({ ok: true, linuxAppRoot: fixture.runtimeRoot }); + const version = runShell(`${sh(fixture.serverEntry)} --version`); + expect(version.status, version.stderr).toBe(0); + expect(version.stdout).toBe("t3code wsl runtime test server 0.0.0\n"); + }).pipe(Effect.provide(layerWslEnvironment(`${fixture.work}/home`))); + }); + }); + it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { const result = runShell( [ diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 57d6c61f7c43..fd557c01489a 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -5,6 +5,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -173,37 +175,19 @@ const buildWslNodeEnvPreamble = ( ensure_remote_node_path || true `; -// wsl.exe re-escaping mangles quotes inside bash -lc scripts. -const runWslShell = ( - distro: string | null, - bashScript: string, +const runWslProcess = ( + args: ReadonlyArray, + stdin: Stream.Stream, timeout: Duration.Duration, - options: { - readonly nodeEngineRange?: string | null; - readonly resolveNode?: boolean; - } = {}, ): Effect.Effect => { const spawner = ChildProcessSpawner.ChildProcessSpawner; - const resolveNode = options.resolveNode !== false; - const command = ChildProcess.make( - "wsl.exe", - resolveNode - ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"] - : [...buildDistroArgs(distro), "--exec", "sh", "-s"], - { - stdin: Stream.encodeText( - Stream.make( - resolveNode - ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}` - : bashScript, - ), - ), - stdout: "pipe", - stderr: "pipe", - killSignal: "SIGTERM", - forceKillAfter: PROCESS_TERMINATE_GRACE, - }, - ); + const command = ChildProcess.make("wsl.exe", args, { + stdin, + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }); return Effect.scoped( Effect.gen(function* () { @@ -249,6 +233,32 @@ const runWslShell = ( ); }; +// wsl.exe re-escaping mangles quotes inside bash -lc scripts. +const runWslShell = ( + distro: string | null, + bashScript: string, + timeout: Duration.Duration, + options: { + readonly nodeEngineRange?: string | null; + readonly resolveNode?: boolean; + } = {}, +): Effect.Effect => { + const resolveNode = options.resolveNode !== false; + return runWslProcess( + resolveNode + ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"] + : [...buildDistroArgs(distro), "--exec", "sh", "-s"], + Stream.encodeText( + Stream.make( + resolveNode + ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}` + : bashScript, + ), + ), + timeout, + ); +}; + const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; @@ -257,13 +267,14 @@ const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_"); +// A null archive path asks for a staging directory to copy the archive into, unless the cache is ready. export const buildWslRuntimeInstallScript = ( - linuxArchivePath: string, + linuxArchivePath: string | null, runtimeId: string, archiveSha256: string, ): string => { const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); - return [ + const readyCheck = [ "set -eu", 'runtime_parent="$HOME/.t3/wsl-runtime"', `runtime_root="$runtime_parent/${safeRuntimeId}"`, @@ -292,6 +303,17 @@ export const buildWslRuntimeInstallScript = ( ` printf 'runtimeRoot:%s\\n' "$runtime_root"`, " exit 0", "fi", + ]; + if (linuxArchivePath === null) { + return [ + ...readyCheck, + // The .tmp. name lets pruning collect a stage orphaned by a crash. + `archive_stage=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, + `printf 'archiveStage:%s\\n' "$archive_stage"`, + ].join("\n"); + } + return [ + ...readyCheck, `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`, `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`, ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`, @@ -409,14 +431,17 @@ export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { ].join("\n"); }; -export const parseWslRuntimeRoot = (stdout: string): string | null => { - const prefix = "runtimeRoot:"; +const parseAbsolutePathField = (stdout: string, field: string): string | null => { + const prefix = `${field}:`; const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); if (line === undefined) return null; - const runtimeRoot = line.slice(prefix.length).replace(/\r$/, ""); - return runtimeRoot.startsWith("/") ? runtimeRoot : null; + const value = line.slice(prefix.length).replace(/\r$/, ""); + return value.startsWith("/") ? value : null; }; +export const parseWslRuntimeRoot = (stdout: string): string | null => + parseAbsolutePathField(stdout, "runtimeRoot"); + const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; const formatNodePtyProbeFailureReason = (exitCode: number): string | null => @@ -775,25 +800,18 @@ const ensureNodePtyImpl = Effect.fn("DesktopWslEnvironment.ensureNodePtyImpl")(f } as const; }); -const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( +type WslRuntimeInstallOutcome = + | { readonly ok: true; readonly stdout: string } + | { readonly ok: false; readonly reason: string }; + +const runWslRuntimeInstall = Effect.fn("desktop.wsl.runRuntimeInstall")(function* ( distro: string | null, archive: WslRuntimeArchive, - windowsToWslPath: ( - distro: string | null, - windowsPath: string, - ) => Effect.Effect>, -): Effect.fn.Return { - const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath); - if (Option.isNone(linuxArchivePath)) { - return { - ok: false, - reason: `wslpath conversion failed for ${archive.windowsPath}`, - } as const; - } - + linuxArchivePath: string | null, +): Effect.fn.Return { const install = yield* runWslShell( distro, - buildWslRuntimeInstallScript(linuxArchivePath.value, archive.runtimeId, archive.sha256), + buildWslRuntimeInstallScript(linuxArchivePath, archive.runtimeId, archive.sha256), RUNTIME_INSTALL_TIMEOUT, { resolveNode: false }, ); @@ -813,14 +831,106 @@ const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(functi reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`, } as const; } + return { ok: true, stdout: install.stdout } as const; +}); - const linuxAppRoot = parseWslRuntimeRoot(install.stdout); +const toPrepareWslRuntimeResult = (outcome: WslRuntimeInstallOutcome): PrepareWslRuntimeResult => { + if (!outcome.ok) return outcome; + const linuxAppRoot = parseWslRuntimeRoot(outcome.stdout); return linuxAppRoot === null ? { ok: false, reason: "WSL runtime installation completed without reporting its cache path.", } : { ok: true, linuxAppRoot }; +}; + +// Without drive mounts (`[automount] enabled=false`) wslpath cannot reach the archive, so copy it in over stdin. +const installWslRuntimeFromHostCopy = Effect.fn("desktop.wsl.installRuntimeFromHostCopy")( + function* ( + distro: string | null, + archive: WslRuntimeArchive, + archiveBytes: Stream.Stream, + ): Effect.fn.Return { + const staged = yield* runWslRuntimeInstall(distro, archive, null); + if (!staged.ok || parseWslRuntimeRoot(staged.stdout) !== null) { + return toPrepareWslRuntimeResult(staged); + } + const stage = parseAbsolutePathField(staged.stdout, "archiveStage"); + if (stage === null) { + return { + ok: false, + reason: `wslpath conversion failed for ${archive.windowsPath}, and the distro did not provide a directory to copy the runtime archive into.`, + } as const; + } + const linuxArchivePath = `${stage}/runtime.tar.gz`; + + const copyAndInstall = Effect.gen(function* () { + const readError = yield* Ref.make(Option.none()); + // dd reads fd 0 itself; reopening /dev/stdin fails when WSL relays stdin as a socket. + const copy = yield* runWslProcess( + [...buildDistroArgs(distro), "--exec", "dd", `of=${linuxArchivePath}`, "bs=65536"], + archiveBytes.pipe( + // Ending stdin on a read error lets dd exit instead of waiting out the timeout. + Stream.catch((error) => + Stream.drain(Stream.fromEffect(Ref.set(readError, Option.some(error)))), + ), + ), + RUNTIME_INSTALL_TIMEOUT, + ); + const failedRead = yield* Ref.get(readError); + if (Option.isSome(failedRead)) { + return { + ok: false, + reason: `Could not read the WSL runtime archive at ${archive.windowsPath}: ${failedRead.value.message}`, + } as const; + } + if (copy.transportFailure !== null || copy.exitCode !== 0) { + const trimmedTail = copy.stderr.trim().slice(-500); + return { + ok: false, + reason: `Could not copy the WSL runtime archive into the distro (exit ${copy.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, + } as const; + } + return toPrepareWslRuntimeResult( + yield* runWslRuntimeInstall(distro, archive, linuxArchivePath), + ); + }); + + return yield* copyAndInstall.pipe( + Effect.ensuring( + runWslShell(distro, `rm -rf ${shellQuote(stage)}`, RUNTIME_INVALIDATE_TIMEOUT, { + resolveNode: false, + }), + ), + ); + }, +); + +const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( + distro: string | null, + archive: WslRuntimeArchive, + windowsToWslPath: ( + distro: string | null, + windowsPath: string, + ) => Effect.Effect>, +): Effect.fn.Return< + PrepareWslRuntimeResult, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem +> { + const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath); + if (Option.isNone(linuxArchivePath)) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* installWslRuntimeFromHostCopy( + distro, + archive, + fileSystem.stream(archive.windowsPath), + ); + } + return toPrepareWslRuntimeResult( + yield* runWslRuntimeInstall(distro, archive, linuxArchivePath.value), + ); }); const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* ( @@ -1198,7 +1308,11 @@ export const layer = Layer.effect( getUserHome, getDistroIp, prepareRuntime: Effect.fn("desktop.wsl.prepareRuntime")(function (distro, archive) { - return provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)); + return provideSpawner( + prepareWslRuntimeImpl(distro, archive, windowsToWslPath).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ), + ); }), pruneRuntimes: Effect.fn("desktop.wsl.pruneRuntimes")(function (distro, runtimeId) { return provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)); diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index e7623faa8fe2..4a42287173b2 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,4 +1,4 @@ -The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers, signed image sources, the `onLinksChange` link list (`renderers`, `resolveImageSource`, `onLinksChange`) and the iOS streaming memory bounds are upstream. The fork carries only changes with an open upstream PR: clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)) and hash-route link fragments ([mity/md4c#454](https://github.com/mity/md4c/pull/454), arriving through [md4c#7](https://github.com/software-mansion-labs/md4c/pull/7)), `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [mity/md4c#477](https://github.com/mity/md4c/pull/477)), GitHub's strikethrough rules for `~` ([md4c#6](https://github.com/software-mansion-labs/md4c/pull/6)), `\(…\)` and `\[…\]` math delimiters ([#1043](https://github.com/software-mansion/enriched-markdown/pull/1043), [md4c#4](https://github.com/software-mansion-labs/md4c/pull/4)), iOS Copy as Markdown keeping bold spans whole ([#1143](https://github.com/software-mansion/enriched-markdown/pull/1143)), line breaks in copied table cells written as `
` ([#1144](https://github.com/software-mansion/enriched-markdown/pull/1144)), and Android measuring text in the span order its view draws it, so code blocks nested in list items no longer make a reply overlap the next message ([#1145](https://github.com/software-mansion/enriched-markdown/pull/1145)). It also carries one fork-only change: iOS code blocks too big for one backing store draw through tiles, so a tall block with long lines no longer shows an empty pane ([pingdotgg/enriched-markdown#15](https://github.com/pingdotgg/enriched-markdown/pull/15)). +The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers, signed image sources, the `onLinksChange` link list (`renderers`, `resolveImageSource`, `onLinksChange`) and the iOS streaming memory bounds are upstream. The fork carries only changes with an open upstream PR: clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)) and hash-route link fragments ([mity/md4c#454](https://github.com/mity/md4c/pull/454), arriving through [md4c#7](https://github.com/software-mansion-labs/md4c/pull/7)), `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [mity/md4c#477](https://github.com/mity/md4c/pull/477)), GitHub's strikethrough rules for `~` ([md4c#6](https://github.com/software-mansion-labs/md4c/pull/6)), `\(…\)` and `\[…\]` math delimiters ([#1043](https://github.com/software-mansion/enriched-markdown/pull/1043), [md4c#4](https://github.com/software-mansion-labs/md4c/pull/4)), iOS Copy as Markdown keeping bold spans whole ([#1143](https://github.com/software-mansion/enriched-markdown/pull/1143)), line breaks in copied table cells written as `
` ([#1144](https://github.com/software-mansion/enriched-markdown/pull/1144)), and Android measuring text in the span order its view draws it, so code blocks nested in list items no longer make a reply overlap the next message ([#1145](https://github.com/software-mansion/enriched-markdown/pull/1145)). It also carries two fork-only changes: iOS code blocks too big for one backing store draw through tiles, so a tall block with long lines no longer shows an empty pane ([pingdotgg/enriched-markdown#15](https://github.com/pingdotgg/enriched-markdown/pull/15)), and a long-pressed iOS blockquote lifts on the background it sits on instead of drawing its text over the thread. To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.18-4287c5d3.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-16db4c75.tgz similarity index 53% rename from apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz rename to apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-16db4c75.tgz index 74fcb8768906..10495faf6f9c 100644 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-16db4c75.tgz differ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift index 0f4e70b20cc3..924fa4ddeb14 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift @@ -19,6 +19,7 @@ final class T3ScrollEdgeBleedView: ExpoView { /// React Native's clipping, saved while bleeding. React Native only rewrites it when the /// `overflow` style changes, so it stays off while bleeding. private var containerClipped: Bool? + private var indicatorObservations: [NSKeyValueObservation] = [] required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -136,12 +137,29 @@ final class T3ScrollEdgeBleedView: ExpoView { containerClipped = containerClipped ?? true container.layer.masksToBounds = false } + applyIndicatorInset() + // React Native rewrites every indicator inset when its `scrollIndicatorInsets` prop changes, + // which the keyboard-aware list animates while a reply streams; put the inset back at once. + if bleeding, indicatorObservations.isEmpty { + let keyPaths = [\UIScrollView.verticalScrollIndicatorInsets, \.scrollIndicatorInsets] + indicatorObservations = keyPaths.map { + scrollView.observe($0) { [weak self] _, _ in + MainActor.assumeIsolated { self?.applyIndicatorInset() } + } + } + } else if !bleeding { + indicatorObservations = [] + } + applied = bleeding + } + + private func applyIndicatorInset() { + guard let scrollView else { return } var indicators = scrollView.verticalScrollIndicatorInsets if indicators.right != bleed.right { indicators.right = bleed.right scrollView.verticalScrollIndicatorInsets = indicators } - applied = bleeding } private func detach() { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index d8f0b29ebce5..1ed02e6a16cb 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -285,7 +285,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != fontSize else { return } inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) - resetSurface() + applyConfigInPlace() } } @@ -321,14 +321,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != appearanceScheme else { return } appearance = TerminalAppearanceScheme(value: appearanceScheme) - resetSurface() + applyConfigInPlace() } } var themeConfig: String = "" { didSet { guard oldValue != themeConfig else { return } - resetSurface() + applyConfigInPlace() } } @@ -663,7 +663,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte surfaceCreationFailed = true return } - loadThemeConfig(into: config) + // Creation proceeds without the file, as before; font_size is also set on the surface config. + _ = loadThemeConfig(into: config) ghostty_config_finalize(config) defer { ghostty_config_free(config) } @@ -710,6 +711,27 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte setNeedsLayout() } + // Font and theme change in place: a new surface would replay output and re-answer its queries. + private func applyConfigInPlace() { + guard let app, let surface else { + resetSurface() + return + } + guard let config = ghostty_config_new() else { return } + defer { ghostty_config_free(config) } + let loaded = loadThemeConfig(into: config) + ghostty_config_finalize(config) + ghostty_app_set_color_scheme(app, appearance.ghosttyColorScheme) + ghostty_surface_set_color_scheme(surface, appearance.ghosttyColorScheme) + tickApp() + // Without the file, the config lacks font-size and would reset the surface to defaults. + if loaded { + ghostty_surface_update_config(surface, config) + } + resizeSurface() + setNeedsLayout() + } + private func destroySurface() { // Unblock the io thread before ghostty_surface_free joins it. tickApp() @@ -933,16 +955,16 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte terminalViewport.backgroundColor = backgroundColorValue } - private func loadThemeConfig(into config: ghostty_config_t) { - guard let path = writeThemeConfigFile() else { return } + private func loadThemeConfig(into config: ghostty_config_t) -> Bool { + guard let path = writeThemeConfigFile() else { return false } path.withCString { cString in ghostty_config_load_file(config, cString) } + return true } private func writeThemeConfigFile() -> String? { - guard !themeConfig.isEmpty else { return nil } - let configContents = themeConfig + let configContents = "\(themeConfig)\nfont-size = \(String(format: "%g", Double(fontSize)))\n" let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty") diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b71a055473e6..08630bbb011c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -127,7 +127,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-native": "0.88.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.18-16db4c75.tgz", "react-native-gesture-handler": "~3.5.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.23.0", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 2c4ba17a7138..1e13b4510cb5 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -76,6 +76,7 @@ import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; import { ForkPushRegistrar } from "./features/fork-push/ForkPushRegistrar"; +import { ConnectDeviceApprovalCoordinator } from "./features/connection/ConnectDeviceApprovalCoordinator"; import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; import { @@ -84,7 +85,10 @@ import { splashCanHide, useLaunchSplashHomeRowsPending, } from "./features/home/home-launch-splash"; -import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; +import { + restoreSettingsLinkState, + settingsLinkAction, +} from "./features/settings/settings-navigation"; import { hydrateWorkspaceSelections, launchWorkspaceURL, @@ -144,14 +148,16 @@ const appLinking = { getActionFromState: ( state: Parameters[0], options: Parameters[1], - ): ReturnType => - // Our router handles the selection; React Navigation's type only lists its own actions. - (workspaceDetailLinkAction(state) as ReturnType) ?? - pairingFormLinkAction( - state, - rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined, - ) ?? - getActionFromState(state, options), + ): ReturnType => { + const current = rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined; + return ( + // Our router handles the selection; React Navigation's type only lists its own actions. + (workspaceDetailLinkAction(state) as ReturnType) ?? + pairingFormLinkAction(state, current) ?? + settingsLinkAction(state, current) ?? + getActionFromState(state, options) + ); + }, getStateFromPath: getAppStateFromPath, }; @@ -278,6 +284,7 @@ function AppContent() { + diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index ea901f244133..8edd7b3d78ab 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -138,6 +138,7 @@ interface MediaVideoPlayerProps { readonly thumbnailVisible?: boolean; readonly visible?: boolean; readonly unavailable?: boolean; + readonly unavailableDetail?: string | undefined; readonly expanded?: boolean; /** Fill the parent and size the frame to the video's shape instead of a fixed 16:9 slot. */ readonly fitToVideo?: boolean; @@ -154,6 +155,9 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + const unavailableText = props.unavailableDetail + ? `Video unavailable. ${props.unavailableDetail}` + : "Video unavailable"; const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); const sizeSource = props.fitToVideo && isFocused && videoSize === null ? props.uri : null; @@ -201,7 +205,9 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { 0 ? "Touch and hold for media actions" : undefined } @@ -223,7 +229,7 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { /> ) : null} {props.unavailable ? ( - Video unavailable + {unavailableText} ) : props.uri === null ? ( ) : ( diff --git a/apps/mobile/src/components/ui/AppBar.tsx b/apps/mobile/src/components/ui/AppBar.tsx index 7690b135f9e1..b691faa300da 100644 --- a/apps/mobile/src/components/ui/AppBar.tsx +++ b/apps/mobile/src/components/ui/AppBar.tsx @@ -1,8 +1,8 @@ import { Toolbar } from "./Toolbar"; import { AppActivityIndicator } from "./AppActivityIndicator"; import type { HeaderBarButtonMailSearchToolbarItem, SearchBarCommands } from "react-native-screens"; -import { NavigationContext } from "@react-navigation/native"; -import { use, useId, useLayoutEffect, useRef } from "react"; +import { NavigationContext, NavigationRouteContext } from "@react-navigation/native"; +import { use, useId, useLayoutEffect, useRef, useSyncExternalStore } from "react"; import { View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; @@ -63,6 +63,17 @@ export function AppBar(props: AppBarProps) { ? { ...props.backInSplitView, onPress: splitBackPress } : null; const navigation = use(NavigationContext); + const route = use(NavigationRouteContext); + // A thread opened from another thread sits on it, not beside the list, so it keeps Back. + const threadBelow = useSyncExternalStore( + (onChange) => navigation?.addListener("state", onChange) ?? (() => {}), + () => { + const routes = navigation?.getState()?.routes; + const index = routes?.findIndex((candidate) => candidate.key === route?.key) ?? -1; + return route?.name === "Thread" && index > 0 && routes?.[index - 1]?.name === "Thread"; + }, + ); + const listBeside = layout.usesSplitView && props.onBackToList !== undefined && !threadBelow; const rootBack = !layout.usesSplitView && props.onBackToList && navigation?.canGoBack() === false ? props.onBackToList @@ -113,7 +124,7 @@ export function AppBar(props: AppBarProps) { headerBackVisible: props.onClose === undefined && splitBack === null && - !(layout.usesSplitView && props.onBackToList) && + !listBeside && !(props.guardBack && props.onBack), ...(props.guardBack ? { gestureEnabled: false } : undefined), title: props.title, diff --git a/apps/mobile/src/components/ui/Dialog.android.test.tsx b/apps/mobile/src/components/ui/Dialog.android.test.tsx index d283cf004c23..ec5abf135637 100644 --- a/apps/mobile/src/components/ui/Dialog.android.test.tsx +++ b/apps/mobile/src/components/ui/Dialog.android.test.tsx @@ -90,6 +90,15 @@ it("keeps a dialog raised while the activity is replaced", () => { expect(current.container.textContent).toContain("Shared content imported"); }); +it("tells a replaced alert it was dismissed", () => { + mountHost(); + const onDismiss = vi.fn(); + act(() => Dialog.alert("Older", undefined, [{ text: "OK" }], { onDismiss })); + act(() => Dialog.alert("Newer", undefined, [{ text: "OK" }])); + + expect(onDismiss).toHaveBeenCalledOnce(); +}); + it("ignores a late action from a dialog that a newer one replaced", () => { const { container } = mountHost(); const olderAction = vi.fn(); diff --git a/apps/mobile/src/components/ui/Dialog.android.tsx b/apps/mobile/src/components/ui/Dialog.android.tsx index 89871fd99a8a..492fa072d10b 100644 --- a/apps/mobile/src/components/ui/Dialog.android.tsx +++ b/apps/mobile/src/components/ui/Dialog.android.tsx @@ -49,7 +49,10 @@ function setPresented(next: PresentedDialog | null) { } function submitRequest(dialog: DialogRequest) { + const replaced = presented; setPresented({ ...dialog, id: ++nextPresentationId }); + // A replaced alert closed without an answer, which its owner hears as a dismissal. + if (replaced?.kind === "alert") replaced.request.options?.onDismiss?.(); } /** Closes the dialog only if it is still the one the action came from. */ diff --git a/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx b/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx index 862d17d60d06..7e961e2f91df 100644 --- a/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx +++ b/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx @@ -1,3 +1,4 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { videoMimeType } from "@t3tools/shared/video"; import { Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -26,6 +27,7 @@ interface PlaybackState { readonly uri: string | null; readonly resolvePlaybackUri?: () => Promise; readonly unavailable: boolean; + readonly unavailableDetail?: string | undefined; readonly error: string | null; readonly actionsSource: MediaActionsSource | undefined; } @@ -46,6 +48,10 @@ function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { uri === null && environmentId !== null && (connection._tag === "None" || asset._tag === "Failure"), + unavailableDetail: + asset._tag === "Failure" && asset.reason === "missing-file-access" + ? MISSING_FILE_ACCESS_MESSAGE + : undefined, error: null, actionsSource: source.actionsSource, }; @@ -134,6 +140,7 @@ function OpenVideoPreviewModal(props: { name={props.name} thumbnailKey={props.thumbnailKey} unavailable={playback.unavailable} + unavailableDetail={playback.unavailableDetail} paused={mediaActions.sharing} expanded autoPlay diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9085f9805806..d4438911eb7b 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -30,12 +30,14 @@ import type { Preferences } from "../../persistence/mobile-preferences"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { __resetAgentAwarenessRemoteRegistrationForTest, + agentAwarenessNotificationsReady, getAgentAwarenessRegistrationStatus, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, + subscribeAgentAwarenessRegistrationStatus, updateAgentAwarenessRegistrationPreferences, type AgentAwarenessRegistrationError, } from "./remoteRegistration"; @@ -709,6 +711,115 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); + + const stubAndroidDeviceRelay = () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + const registrations: Array<{ + readonly preferences: { readonly notificationsEnabled: boolean }; + }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + if (request.url.endsWith("/v1/mobile/devices")) { + registrations.push(await request.json()); + } + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + return registrations; + }; + + it.effect("does not report notifications ready when its push token lookup fails", () => { + const registrations = stubAndroidDeviceRelay(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue( + new Error("Default FirebaseApp is not initialized"), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + + // Live Activities register with their own tokens, so the device stays registered. + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + expect(agentAwarenessNotificationsReady()).toBe(false); + expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); + expect(registrations[0]).not.toHaveProperty("pushToken"); + + const onStatusChange = vi.fn(); + subscribeAgentAwarenessRegistrationStatus(onStatusChange); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: "android", data: "fcm-token" } as never); + yield* runBackgroundOperations(); + + expect(agentAwarenessNotificationsReady()).toBe(true); + expect(onStatusChange).toHaveBeenCalled(); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + + it.effect("keeps notifications enabled when a refresh cannot read the push token", () => { + const registrations = stubAndroidDeviceRelay(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValueOnce( + new Error("SERVICE_NOT_AVAILABLE"), + ); + yield* refreshAgentAwarenessRegistration(); + + expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); + expect(agentAwarenessNotificationsReady()).toBe(true); + + // A lookup that started before a rotation must not replace the rotated token. + let resolveStaleLookup: (token: { type: "android"; data: string }) => void = () => {}; + let markLookupStarted: () => void = () => {}; + const lookupStarted = new Promise((resolve) => { + markLookupStarted = resolve; + }); + vi.mocked(Notifications.getDevicePushTokenAsync).mockImplementationOnce(() => { + markLookupStarted(); + return new Promise((resolve) => { + resolveStaleLookup = resolve; + }); + }); + const staleRefresh = yield* Effect.forkChild(refreshAgentAwarenessRegistration()); + yield* Effect.promise(() => lookupStarted); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: "android", data: "rotated-fcm-token" } as never); + yield* runBackgroundOperations(); + resolveStaleLookup({ type: "android", data: "fcm-token" }); + yield* Fiber.join(staleRefresh); + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValueOnce( + new Error("SERVICE_NOT_AVAILABLE"), + ); + yield* refreshAgentAwarenessRegistration(); + + expect(registrations.at(-1)).toMatchObject({ pushToken: "rotated-fcm-token" }); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + it.effect.each(["blocked", "unavailable"] as const)( "keeps Android push delivery registered when the alert channel is %s", (channelState) => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index ec712608fad9..a2b972b0ccc2 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -95,6 +95,9 @@ const registeredActivityPushTokens = new Map(); // repaint, so a slow older response never overwrites newer rows. let widgetSnapshotReads = 0; let androidDeviceReplayedAt: number | null = null; +let lastNativePushToken: string | null = null; +let registeredWithPushToken = false; +let nativePushTokenRotations = 0; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -102,11 +105,15 @@ export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "register let registrationStatus: AgentAwarenessRegistrationStatus = "unknown"; const registrationStatusListeners = new Set<() => void>(); -function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void { - if (registrationStatus === next) { +function setRegistrationStatus( + next: AgentAwarenessRegistrationStatus, + withPushToken = registeredWithPushToken, +): void { + if (registrationStatus === next && registeredWithPushToken === withPushToken) { return; } registrationStatus = next; + registeredWithPushToken = withPushToken; for (const listener of registrationStatusListeners) { listener(); } @@ -116,6 +123,12 @@ export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistratio return registrationStatus; } +// Live Activities use their own push tokens, so device registration alone does not mean +// the relay can deliver notifications. +export function agentAwarenessNotificationsReady(): boolean { + return registrationStatus === "registered" && registeredWithPushToken; +} + export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void { registrationStatusListeners.add(listener); return () => { @@ -291,12 +304,15 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke ) : true); if (observedPushToken) { + lastNativePushToken = observedPushToken; + nativePushTokenRotations++; return { notificationsEnabled: alertsEnabled, pushToken: observedPushToken, notificationPermissionGranted: permissions.granted, }; } + const rotationsBeforeLookup = nativePushTokenRotations; const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), catch: (cause) => @@ -312,12 +328,18 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke ), Effect.orElseSucceed(() => null), ); - const pushToken = + const lookedUpPushToken = token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0 ? token.data.trim() : null; + // A rotation observed while this lookup ran is newer than what the lookup returned. + if (lookedUpPushToken && rotationsBeforeLookup === nativePushTokenRotations) { + lastNativePushToken = lookedUpPushToken; + } + const pushToken = lastNativePushToken; + // A missing token must not disable notifications the relay accepted. return { - notificationsEnabled: alertsEnabled && pushToken !== null, + notificationsEnabled: alertsEnabled, pushToken, notificationPermissionGranted: permissions.granted, }; @@ -405,7 +427,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith persisted.signature === signature && !needsAndroidReplay ) { - setRegistrationStatus("registered"); + setRegistrationStatus("registered", Boolean(payload.pushToken)); return; } @@ -421,7 +443,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith return; } if (body.platform === "android") androidDeviceReplayedAt = yield* Clock.currentTimeMillis; - setRegistrationStatus("registered"); + setRegistrationStatus("registered", Boolean(payload.pushToken)); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ identity, @@ -855,7 +877,10 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { activeDeviceRegistration = null; pendingDeviceRegistration = null; registrationStatus = "unknown"; + registeredWithPushToken = false; androidDeviceReplayedAt = null; + lastNativePushToken = null; + nativePushTokenRotations = 0; registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 6d1af92d5fb9..c6ebf6bab46d 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -203,6 +203,7 @@ function ConnectedCloudEnvironmentRow(props: { connectionError={enabled || unsupported ? props.environment.connectionError : null} connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null} connectionState={enabled || unsupported ? props.environment.connectionState : "available"} + awaitingApproval={enabled && presentation?.connection.awaitingApproval === true} errorExpanded={props.errorExpanded} label={props.environment.environmentLabel} machine={resolveEnvironmentMachineKind( @@ -269,6 +270,8 @@ function CloudEnvironmentRowShell(props: { readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; readonly connectionState: EnvironmentConnectionPhase; + /** Waiting on the host to approve this device: say so plainly, not as a failure. */ + readonly awaitingApproval?: boolean; readonly disabled?: boolean; readonly errorExpanded: boolean; readonly label: string; @@ -287,9 +290,10 @@ function CloudEnvironmentRowShell(props: { phase: props.connectionState, error: props.connectionError, traceId: props.connectionErrorTraceId, + ...(props.awaitingApproval ? { awaitingApproval: true as const } : {}), }); const statusClassName = - props.connectionError && props.connectionState !== "unsupported" + props.connectionError && props.connectionState !== "unsupported" && !props.awaitingApproval ? "text-danger-foreground" : "text-foreground-muted"; const [errorMeasurement, setErrorMeasurement] = useState<{ diff --git a/apps/mobile/src/features/connection/ConnectDeviceApprovalCoordinator.tsx b/apps/mobile/src/features/connection/ConnectDeviceApprovalCoordinator.tsx new file mode 100644 index 000000000000..fd2b9ae42556 --- /dev/null +++ b/apps/mobile/src/features/connection/ConnectDeviceApprovalCoordinator.tsx @@ -0,0 +1,141 @@ +import { AuthAccessWriteScope, type AuthPairingLink, type EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useEffectEvent } from "react"; + +import * as Dialog from "../../components/ui/Dialog"; +import { authEnvironment } from "../../state/auth"; +import { useEnvironments } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { useEnvironmentsWithScope } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + type ConnectDeviceApprovalPrompt, + connectDeviceApprovalPrompts, + createApprovalPromptQueue, +} from "./connectDeviceApproval"; + +/** Asks to approve T3 Connect devices waiting on hosts that require it and this phone manages. */ +export function ConnectDeviceApprovalCoordinator() { + const { environments } = useEnvironments(); + const requiring = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.settings.requireConnectDeviceApproval === true, + ); + const managed = useEnvironmentsWithScope(requiring, AuthAccessWriteScope); + return ( + <> + {requiring + .filter((environment) => managed.has(environment.environmentId)) + .map((environment) => ( + + ))} + + ); +} + +/** "gone": the request expired or was answered elsewhere before this answer arrived. */ +type ApprovalAnswer = "done" | "failed" | "gone"; + +interface QueuedApprovalPrompt extends ConnectDeviceApprovalPrompt { + readonly environmentId: EnvironmentId; + readonly approve: () => Promise; + readonly deny: () => Promise; +} + +const watchedEnvironments = new Set(); + +// One queue for every host: a second Android alert would replace an unanswered first one. +const approvalPrompts = createApprovalPromptQueue((prompt, answer) => { + const respond = (action: () => Promise, failure: string) => () => + void action().then((outcome) => { + // A host that went away or lost access cannot be retried; let the next prompt show. + if (outcome === "done" || !watchedEnvironments.has(prompt.environmentId)) { + return answer("done"); + } + if (outcome === "gone") { + return Dialog.alert( + "Request no longer waiting", + "It expired or was answered elsewhere.", + [{ text: "OK", onPress: () => answer("done") }], + { onDismiss: () => answer("done") }, + ); + } + const retryIfWatched = () => + answer(watchedEnvironments.has(prompt.environmentId) ? "retry" : "done"); + Dialog.alert( + failure, + "The request is still waiting. Try again.", + [{ text: "OK", onPress: retryIfWatched }], + // Another dialog took the screen; offer the request again on the next update. + { onDismiss: () => answer("later") }, + ); + }); + Dialog.alert( + prompt.title, + prompt.message, + [ + { text: "Deny", onPress: respond(prompt.deny, "Could not deny the device") }, + { text: "Approve", onPress: respond(prompt.approve, "Could not approve the device") }, + ], + // Another dialog took the screen; offer the request again on the next update. + { cancelable: false, onDismiss: () => answer("later") }, + ); +}); + +function EnvironmentApprovalWatcher(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const access = useEnvironmentQuery( + authEnvironment.accessChanges({ environmentId: props.environmentId, input: null }), + ); + const approve = useAtomCommand(authEnvironment.approveConnectDevice, { reportFailure: false }); + const deny = useAtomCommand(authEnvironment.revokePairingLink, { reportFailure: false }); + const pairingLinks = + access.data?.type === "snapshot" ? access.data.payload.pairingLinks : undefined; + + const offerPrompts = useEffectEvent((links: ReadonlyArray) => { + const prompts = connectDeviceApprovalPrompts({ + environmentLabel: props.environmentLabel, + pairingLinks: links, + shownIds: approvalPrompts.known, + }); + approvalPrompts.offer( + prompts.map((prompt) => ({ + ...prompt, + environmentId: props.environmentId, + approve: () => + approve({ + environmentId: props.environmentId, + input: { pairingLinkId: prompt.pairingLinkId }, + }).then((result) => + result._tag !== "Success" ? "failed" : result.value.approved ? "done" : "gone", + ), + deny: () => + deny({ environmentId: props.environmentId, input: { id: prompt.pairingLinkId } }).then( + (result) => + result._tag !== "Success" ? "failed" : result.value.revoked ? "done" : "gone", + ), + })), + ); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers this host with the module-level prompt queue and drops its prompts when it goes away + useEffect(() => { + const environmentId = props.environmentId; + watchedEnvironments.add(environmentId); + return () => { + watchedEnvironments.delete(environmentId); + approvalPrompts.drop((prompt) => prompt.environmentId === environmentId); + }; + }, [props.environmentId]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a new request on the access stream opens a native alert + useEffect(() => { + if (pairingLinks !== undefined) offerPrompts(pairingLinks); + }, [pairingLinks]); + + return null; +} diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index a3e9f38d30a7..7aa3adf08ca1 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -10,7 +10,12 @@ import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; -function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string { +function noticeTitle( + phase: EnvironmentConnectionPhase, + environmentLabel: string, + awaitingApproval: boolean, +): string { + if (awaitingApproval) return `Waiting for ${environmentLabel} to approve this device`; switch (phase) { case "offline": return "You are offline"; @@ -33,7 +38,9 @@ function noticeDetail( phase: EnvironmentConnectionPhase, resourceName: string, error: string | null, + awaitingApproval: boolean, ): string { + if (error && awaitingApproval) return error; if (error) { return phase === "reconnecting" ? `The app will keep retrying automatically. ${error}` : error; } @@ -78,10 +85,19 @@ export function EnvironmentConnectionNotice(props: { )} - {noticeTitle(props.connection.phase, props.environmentLabel)} + {noticeTitle( + props.connection.phase, + props.environmentLabel, + props.connection.awaitingApproval === true, + )} - {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} + {noticeDetail( + props.connection.phase, + props.resourceName, + props.connection.error, + props.connection.awaitingApproval === true, + )} {props.connection.traceId ? ( ) : null} diff --git a/apps/mobile/src/features/connection/connectDeviceApproval.test.ts b/apps/mobile/src/features/connection/connectDeviceApproval.test.ts new file mode 100644 index 000000000000..2d5d8437ae80 --- /dev/null +++ b/apps/mobile/src/features/connection/connectDeviceApproval.test.ts @@ -0,0 +1,113 @@ +import type { AuthPairingLink } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { connectDeviceApprovalPrompts, createApprovalPromptQueue } from "./connectDeviceApproval"; + +const link = (id: string, createdAtMs: number, request?: AuthPairingLink["connectDeviceRequest"]) => + ({ + id, + scopes: [], + subject: "cloud-connect", + createdAt: DateTime.makeUnsafe(createdAtMs), + expiresAt: DateTime.makeUnsafe(createdAtMs + 600_000), + ...(request ? { connectDeviceRequest: request } : {}), + }) as unknown as AuthPairingLink; + +describe("connectDeviceApprovalPrompts", () => { + it("prompts once per waiting device, oldest first, and skips ordinary pairing links", () => { + const pairingLinks = [ + link("newer", 2_000, { verificationCode: "WXYZ-2345" }), + link("plain", 500), + link("older", 1_000, { + verificationCode: "ABCD-EFGH", + client: { deviceType: "desktop", browser: "Chrome", os: "macOS" }, + }), + ]; + + const prompts = connectDeviceApprovalPrompts({ + environmentLabel: "Mac mini", + pairingLinks, + shownIds: new Set(["newer"]), + }); + + expect(prompts.map((prompt) => prompt.pairingLinkId)).toEqual(["older"]); + expect(prompts[0]?.message).toContain("Chrome on macOS wants to connect to Mac mini"); + expect(prompts[0]?.message).toContain("ABCD-EFGH"); + }); +}); + +describe("createApprovalPromptQueue", () => { + it("shows one prompt at a time and offers a failed one again before the rest", () => { + const shown: string[] = []; + const answers: Array<(outcome: "done" | "retry" | "later") => void> = []; + const queue = createApprovalPromptQueue<{ pairingLinkId: string }>((prompt, answer) => { + shown.push(prompt.pairingLinkId); + answers.push(answer); + }); + + queue.offer([{ pairingLinkId: "a" }, { pairingLinkId: "b" }]); + queue.offer([{ pairingLinkId: "a" }]); + expect(shown).toEqual(["a"]); + + answers.at(-1)!("retry"); + expect(shown).toEqual(["a", "a"]); + answers.at(-1)!("done"); + expect(shown).toEqual(["a", "a", "b"]); + }); + + it("waits for the next offer when another dialog replaced the prompt", () => { + const shown: string[] = []; + const answers: Array<(outcome: "done" | "retry" | "later") => void> = []; + const queue = createApprovalPromptQueue<{ pairingLinkId: string }>((prompt, answer) => { + shown.push(prompt.pairingLinkId); + answers.push(answer); + }); + + queue.offer([{ pairingLinkId: "a" }]); + answers.at(-1)!("later"); + expect(shown).toEqual(["a"]); + queue.offer([]); + expect(shown).toEqual(["a", "a"]); + }); + + it("offers a dropped host's on-screen prompt again once the host is back", () => { + const shown: string[] = []; + const answers: Array<(outcome: "done" | "retry" | "later") => void> = []; + const queue = createApprovalPromptQueue<{ pairingLinkId: string; host: string }>( + (prompt, answer) => { + shown.push(prompt.pairingLinkId); + answers.push(answer); + }, + ); + + queue.offer([{ pairingLinkId: "a", host: "mini" }]); + queue.drop((prompt) => prompt.host === "mini"); + answers.at(-1)!("retry"); + expect(shown).toEqual(["a"]); + queue.offer([{ pairingLinkId: "a", host: "mini" }]); + expect(shown).toEqual(["a", "a"]); + }); + + it("drops a host's waiting prompts so they cannot hold up other hosts", () => { + const shown: string[] = []; + const answers: Array<(outcome: "done" | "retry" | "later") => void> = []; + const queue = createApprovalPromptQueue<{ pairingLinkId: string; host: string }>( + (prompt, answer) => { + shown.push(prompt.pairingLinkId); + answers.push(answer); + }, + ); + + queue.offer([ + { pairingLinkId: "a", host: "mini" }, + { pairingLinkId: "b", host: "mini" }, + { pairingLinkId: "c", host: "laptop" }, + ]); + queue.drop((prompt) => prompt.host === "mini"); + answers.at(-1)!("done"); + expect(shown).toEqual(["a", "c"]); + // A dropped host's request can be offered again once the host is back. + expect(queue.known.has("b")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/connection/connectDeviceApproval.ts b/apps/mobile/src/features/connection/connectDeviceApproval.ts new file mode 100644 index 000000000000..796b545381d1 --- /dev/null +++ b/apps/mobile/src/features/connection/connectDeviceApproval.ts @@ -0,0 +1,91 @@ +import type { AuthClientMetadata, AuthPairingLink } from "@t3tools/contracts"; + +export interface ConnectDeviceApprovalPrompt { + readonly pairingLinkId: string; + readonly title: string; + readonly message: string; +} + +export function describeConnectDevice(device: AuthClientMetadata | undefined): string { + const { label, browser, os } = device ?? {}; + if (label) return label; + if (browser && os) return `${browser} on ${os}`; + return browser ?? os ?? "A new device"; +} + +/** Requests waiting on approval that have not been shown yet, oldest first. */ +export function connectDeviceApprovalPrompts(input: { + readonly environmentLabel: string; + readonly pairingLinks: ReadonlyArray; + readonly shownIds: ReadonlySet; +}): ReadonlyArray { + const waiting = input.pairingLinks.flatMap((link) => + link.connectDeviceRequest !== undefined && !input.shownIds.has(link.id) + ? [{ link, ...link.connectDeviceRequest }] + : [], + ); + return [...waiting] + .sort( + (left, right) => + left.link.createdAt.epochMilliseconds - right.link.createdAt.epochMilliseconds, + ) + .map(({ link, verificationCode, client: device }) => ({ + pairingLinkId: link.id, + title: "Approve this device?", + message: `${describeConnectDevice(device)} wants to connect to ${input.environmentLabel} through T3 Connect. It shows code ${verificationCode}. Approve it only if that code matches.`, + })); +} + +/** + * Shows approval prompts one at a time: an Android alert replaces the one on screen, which + * would drop an unanswered request. A prompt answered with "retry" goes back to the front; + * "later" puts it back without showing anything until the next offer. + */ +export function createApprovalPromptQueue

( + present: (prompt: P, answer: (outcome: "done" | "retry" | "later") => void) => void, +) { + const known = new Set(); + const waiting: P[] = []; + let showing: { readonly prompt: P; dropped: boolean } | null = null; + const next = () => { + if (showing) return; + const prompt = waiting.shift(); + if (prompt === undefined) return; + const current = { prompt, dropped: false }; + showing = current; + let answered = false; + present(prompt, (outcome) => { + if (answered) return; + answered = true; + showing = null; + // A dropped prompt is forgotten, so its host can offer it again when it comes back. + if (outcome !== "done" && !current.dropped) waiting.unshift(prompt); + if (outcome !== "later") next(); + }); + }; + return { + known: known as ReadonlySet, + /** Forgets waiting prompts that match, such as a host that went away. */ + drop(matches: (prompt: P) => boolean) { + if (showing && matches(showing.prompt)) { + showing.dropped = true; + known.delete(showing.prompt.pairingLinkId); + } + for (let index = waiting.length - 1; index >= 0; index--) { + const prompt = waiting[index]!; + if (!matches(prompt)) continue; + waiting.splice(index, 1); + known.delete(prompt.pairingLinkId); + } + }, + offer(prompts: ReadonlyArray

) { + // Also re-shows a prompt deferred because another dialog took the screen. + for (const prompt of prompts) { + if (known.has(prompt.pairingLinkId)) continue; + known.add(prompt.pairingLinkId); + waiting.push(prompt); + } + next(); + }, + }; +} diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts index a2d85096e351..46751736a4be 100644 --- a/apps/mobile/src/features/connection/connectionTone.ts +++ b/apps/mobile/src/features/connection/connectionTone.ts @@ -1,7 +1,17 @@ import type { StatusTone } from "../../components/StatusPill"; import type { RemoteClientConnectionState } from "../../lib/connection"; -export function connectionTone(state: RemoteClientConnectionState): StatusTone { +export function connectionTone( + state: RemoteClientConnectionState, + awaitingApproval?: boolean, +): StatusTone { + if (state === "reconnecting" && awaitingApproval) { + return { + label: "Waiting for approval", + pillClassName: "bg-update", + textClassName: "text-update-foreground", + }; + } switch (state) { case "connected": return { diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx index 0c40fc4e088b..b962d716b992 100644 --- a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -63,6 +63,7 @@ function DocumentWebPreview(props: { readonly uri: string }) { originWhitelist={["*"]} allowsBackForwardNavigationGestures allowsFullscreenVideo + allowsInlineMediaPlayback setSupportMultipleWindows={false} onShouldStartLoadWithRequest={(request) => { if (request.isTopFrame !== false && isPdfPreviewUri(request.url)) { diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index df5142c15e24..6fdaa2891492 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -9,10 +9,7 @@ import { useMemo, useState } from "react"; import { Platform } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { - pickableProjectGroups, - resolveScratchProjectScopeKey, -} from "@t3tools/client-runtime/state/project-grouping"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { useProjects } from "../../state/entities"; import { steadyNavigationThreadShellsAtom } from "../../state/threads"; @@ -31,7 +28,11 @@ import { useAtomValueWhileVisible, useHomeRouteVisible, } from "./home-route-visibility"; -import { buildHomeProjectScopes } from "./homeThreadList"; +import { + buildHomeProjectScopes, + resolveHomeProjectFilterScope, + sortHomeProjectScopes, +} from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; @@ -130,35 +131,32 @@ export function HomeRouteScreen() { setSelectedProjectKey, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - // A legacy per-environment scratch key resolves here on every render; the stored - // value is only rewritten when the user picks a filter. - const resolvedProjectKey = resolveScratchProjectScopeKey( - listOptions.selectedProjectKey, - projects, - { - sidebarProjectGroupingMode: listOptions.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }, + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode: listOptions.projectGroupingMode, + }), + [listOptions.projectGroupingMode, projects, selectedEnvironmentId], ); const projectFilterOptions = useMemo( () => pickableProjectGroups( - buildHomeProjectScopes({ - projects, - environmentId: selectedEnvironmentId, - projectGroupingMode: listOptions.projectGroupingMode, + sortHomeProjectScopes({ + scopes: projectScopes, + threads, + pendingTasks: [], + projectSortOrder: "updated_at", }), ).map((scope) => ({ key: scope.key, label: scope.title, })), - [listOptions.projectGroupingMode, projects, selectedEnvironmentId], + [projectScopes, threads], ); - const selectedProjectKey = projectFilterOptions.some( - (project) => project.key === resolvedProjectKey, - ) - ? resolvedProjectKey - : null; + const selectedProjectKey = + resolveHomeProjectFilterScope(projectScopes, projects, listOptions)?.key ?? null; if (layout.usesSplitView && !nativePrimaryColumn) { return ( diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1ab32e1bb6b1..f6480308a927 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -92,6 +92,7 @@ import { buildHomeProjectScopes, findHomeProjectScope, type HomeProjectSortOrder, + sortHomeProjectScopes, } from "./homeThreadList"; import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; @@ -688,7 +689,14 @@ export function HomeScreen(props: HomeScreenProps) { ({ + projects={pickableProjectGroups( + sortHomeProjectScopes({ + scopes: projectScopes, + threads: props.threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }), + ).map((scope) => ({ key: scope.key, label: scope.title, }))} diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 1b2e1a8c541f..7c0392497645 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,4 +1,8 @@ -import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; +import { + buildProjectGroups, + pickableProjectGroups, + resolveScratchProjectScopeKey, +} from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -70,6 +74,24 @@ export function buildHomeProjectScopes(input: { }); } +/** The scope the thread list's project filter shows; an archived or unknown key shows all projects. */ +export function resolveHomeProjectFilterScope( + scopes: ReadonlyArray, + projects: ReadonlyArray, + options: { + readonly selectedProjectKey: string | null; + readonly projectGroupingMode: SidebarProjectGroupingMode; + }, +): HomeProjectScope | null { + // A legacy per-environment scratch key resolves here; the stored value is only + // rewritten when the user picks a filter. + const key = resolveScratchProjectScopeKey(options.selectedProjectKey, projects, { + sidebarProjectGroupingMode: options.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }); + return pickableProjectGroups(scopes).find((scope) => scope.key === key) ?? null; +} + export function findHomeProjectScope( scopes: ReadonlyArray, key: string | null, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 3a32e3352361..a90ba87eedda 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -59,6 +59,8 @@ import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; import { mobilePreferencesAtom } from "../../state/preferences"; +import { savedConnectionsByIdAtom } from "../../state/use-remote-environment-registry"; +import { workspaceConnections } from "../../state/workspace"; import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, resolveMobileProjectGroupingSettings, @@ -83,7 +85,8 @@ import { import { startOpeningDetail } from "./workspace/useSelectWorkspaceDetail"; import { useWorkspaceTabs } from "./workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; -import { newTaskDestination } from "../threads/new-task-project-selection"; +import { buildHomeProjectScopes, resolveHomeProjectFilterScope } from "../home/homeThreadList"; +import { newTaskDestination, newTaskFilteredProject } from "../threads/new-task-project-selection"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { PaneContext } from "../../components/ui/Pane"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; @@ -606,12 +609,38 @@ function AdaptiveWorkspaceLayoutContent( : undefined; const preferences = appAtomRegistry.get(mobilePreferencesAtom); const saved = AsyncResult.isSuccess(preferences) ? preferences.value : undefined; + // Resolve the thread list's filter as useHomeListOptions does: a removed connection's filter reads as all. + const listFilters = saved?.threadListFilters; + const listEnvironmentId = + listFilters?.environmentId != null && + appAtomRegistry.get(savedConnectionsByIdAtom)[listFilters.environmentId] !== undefined + ? listFilters.environmentId + : null; + const filteredScope = resolveHomeProjectFilterScope( + buildHomeProjectScopes({ projects, environmentId: listEnvironmentId, projectGroupingMode }), + projects, + { selectedProjectKey: listFilters?.projectKey ?? null, projectGroupingMode }, + ); + const connectedEnvironmentIds = new Set( + appAtomRegistry + .get(workspaceConnections.environmentsAtom) + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); handleSelectThreadRoute( newTaskDestination({ projects, requestId: uuidv4(), useLastProject: saved?.useLastProjectForNewThreads, currentProject: environmentId && projectId ? { environmentId, projectId } : undefined, + filteredProject: filteredScope + ? newTaskFilteredProject( + filteredScope, + listEnvironmentId, + connectedEnvironmentIds, + saved?.lastThreadProject, + ) + : undefined, lastProject: saved?.lastThreadProject, }), ); diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx index a03a36bc9476..75d139621562 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx @@ -24,6 +24,8 @@ import { WorkspaceAppBarScope } from "../../../components/ui/WorkspaceAppBar.and import { usePaneRole } from "../../../components/ui/Pane"; import { AndroidWorkspaceSidebar } from "./AndroidWorkspaceSidebar.android"; import type { WorkspaceTab } from "./workspace-tabs-state"; +import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; +import { useHardwareBack } from "../../../lib/backHandler"; const TABS: readonly WorkspaceTab[] = NAVIGATION_TAB_ORDER; @@ -49,6 +51,18 @@ function WorkspaceRoot() { : 16, }; const tab = workspace.state.selectedTab; + const selectTab = useSelectWorkspaceTab(); + // Tabs are panes of Home, not routes, so Back has nothing to pop and would leave the app. + // An open search keeps Back for itself. + useHardwareBack( + tab !== "threads" && + enabled.includes("threads") && + workspace.state.tabs[tab].searchMode === "resting", + () => { + selectTab("threads"); + return true; + }, + ); return ( diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts index 3f217fd70c8b..3cd3fe6d0978 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts @@ -1,6 +1,7 @@ +import * as NodeModule from "node:module"; import { describe, expect, it } from "vite-plus/test"; -import { restoreSettingsLinkState } from "../../settings/settings-navigation"; +import { restoreSettingsLinkState, settingsLinkAction } from "../../settings/settings-navigation"; import { workspaceSelectionsIn, workspaceTabColumns, @@ -19,6 +20,17 @@ const pullRequest = { params: { environmentId: "env", projectId: "project", repository: "o/r", number: 7 }, }; const settings = { key: "settings", name: "Settings" }; +const requireFromNative = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), +); +const { getStateFromPath } = requireFromNative("@react-navigation/core") as { + readonly getStateFromPath: typeof import("@react-navigation/native").getStateFromPath; +}; +const { StackRouter } = NodeModule.createRequire( + requireFromNative.resolve("@react-navigation/core/package.json"), +)("@react-navigation/routers") as { + readonly StackRouter: typeof import("@react-navigation/native").StackRouter; +}; const available = { thread: () => true, pullRequest: () => true }; const names = (state: { routes: readonly { name: string }[] }) => state.routes.map((route) => route.name); @@ -218,6 +230,31 @@ describe("Settings links", () => { expect(restoreSettingsLinkState(bare, undefined)).toBe(bare); }); + it("opens a link sent to the running app on the Settings tab, as its button does", () => { + const screens = { + Home: "", + Settings: "settings", + SettingsAbout: "settings/about", + SettingsAppearance: "settings/appearance", + }; + const router = StackRouter({}); + const options = { routeNames: Object.keys(screens), routeParamList: {}, routeGetIdList: {} }; + const open = router.getRehydratedState( + { index: 2, routes: [{ name: "Home" }, { name: "Settings" }, { name: "SettingsAbout" }] }, + options, + ); + const follow = (path: string) => { + const link = getStateFromPath(path, { initialRouteName: "Home", screens })!; + const action = settingsLinkAction(link, open)!; + return router.getStateForAction(open, action, options)!; + }; + + expect(names(follow("settings"))).toEqual(["Home", "Settings"]); + const appearance = follow("settings/appearance"); + expect(names(appearance)).toEqual(["Home", "Settings", "SettingsAppearance"]); + expect(appearance.routes[1]!.key).toBe(open.routes[1]!.key); + }); + it("opens the sign-in link as a modal over the workspace", () => { const state = { routes: [{ name: "Home" }, { name: "SettingsAuth" }] }; expect(restoreSettingsLinkState(state, "SettingsAppearance")).toBe(state); diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts index f1c839630eb3..797da115a20e 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts @@ -11,6 +11,8 @@ import { workspaceSearchBottomClearance, workspaceDockBottomClearance, workspaceUsesColumns, + type WorkspaceTab, + type WorkspaceTabsState, } from "./workspace-tabs-state"; describe("compact list clearance", () => { @@ -179,12 +181,25 @@ describe("workspace root navigation", () => { }); }); -const mocks = vi.hoisted(() => ({ split: false, floating: false })); +const mocks = vi.hoisted(() => ({ + split: false, + floating: false, + state: undefined as WorkspaceTabsState | undefined, + openWorkspaceTab: (_tab: string) => {}, + backListeners: new Set<() => boolean>(), +})); vi.mock("../../settings/appearance/AppearancePreferencesProvider", () => ({ useAndroidFloatingTabs: () => mocks.floating, })); vi.mock("react-native", () => ({ + BackHandler: { + addEventListener: (_event: string, listener: () => boolean) => { + mocks.backListeners.add(listener); + return { remove: () => mocks.backListeners.delete(listener) }; + }, + }, + Keyboard: { dismiss: () => undefined }, useWindowDimensions: () => ({ width: 400, height: 800 }), View: ({ children }: { children: ReactNode }) => createElement("div", null, children), })); @@ -198,12 +213,16 @@ vi.mock("../AdaptiveWorkspaceLayout", () => ({ startNewTask: () => undefined, }), })); +vi.mock("@react-navigation/native", () => ({ useIsFocused: () => true })); +vi.mock("../adaptive-workspace-context", () => ({ + useAdaptiveWorkspaceLayout: () => ({ openWorkspaceTab: mocks.openWorkspaceTab }), +})); vi.mock("./navigation-tabs", () => ({ useEnabledNavigationTabs: () => ["threads", "pullRequests", "usage"], })); vi.mock("./WorkspaceTabsContext", () => ({ WorkspaceTabsContext: createContext(null), - useWorkspaceTabs: () => ({ state: { selectedTab: "usage" } }), + useWorkspaceTabs: () => ({ state: mocks.state, dispatch: () => undefined }), })); vi.mock("./WorkspaceExtraTab", () => ({ WorkspaceExtraTab: function UsageProbe() { @@ -238,13 +257,37 @@ vi.mock("../../../components/ui/WorkspaceAppBar.android", () => ({ WorkspaceAppBarScope: ({ children }: { children: ReactNode }) => children, })); -function setup(split: boolean, floating: boolean) { +function setup(split: boolean, floating: boolean, state = selected("usage")) { mocks.split = split; mocks.floating = floating; + mocks.state = state; + mocks.openWorkspaceTab = () => undefined; + mocks.backListeners.clear(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); return { [Symbol.dispose]: () => vi.unstubAllGlobals() }; } +function selected(tab: WorkspaceTab) { + return workspaceTabsReducer(initialWorkspaceTabsState(), { type: "select", tab }); +} + +/** Android calls the newest listener first and stops at the first that handles Back. */ +function pressBack() { + return [...mocks.backListeners].reverse().some((listener) => listener()); +} + +it("Back from the Pulls tab returns to Threads instead of leaving the app", async () => { + using _actEnvironment = setup(false, false, selected("pullRequests")); + const opened: string[] = []; + mocks.openWorkspaceTab = (tab) => opened.push(tab); + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + await act(async () => root.render(createElement(WorkspaceRootScreen))); + + expect(pressBack()).toBe(true); + expect(opened).toEqual(["threads"]); +}); + it.each([ [false, false], [true, false], diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index f6b1c6fd1d6a..cd80c2510580 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -6,7 +6,11 @@ import * as NodeModule from "node:module"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { workspaceDetailRouter } from "../layout/workspace/workspace-detail-selection"; import type { NavigationState } from "@react-navigation/native"; -import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { act, createElement, isValidElement, type ReactNode } from "react"; @@ -22,7 +26,9 @@ const state = vi.hoisted(() => ({ id: string; title: string; workspaceRoot: string; + repositoryIdentity?: { canonicalKey: string }; }[], + cloneDestinations: [] as string[], createResult: null as Promise | null, createNewResult: null as Promise | null, startCloneResult: null as Promise | null, @@ -134,7 +140,15 @@ vi.mock("../../state/use-remote-environment-registry", () => ({ }), })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); -vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} })); +// Only filesystem.browse of `~` answers, with the host's home as its parentPath. +vi.mock("../../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: + () => + async ({ input }: { input?: { partialPath?: string } }) => + input?.partialPath === "~" + ? AsyncResult.success({ parentPath: "/home/tester", entries: [] }) + : undefined, +})); vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); vi.mock("../../state/presentation", () => ({ useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), @@ -145,6 +159,7 @@ vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { discovery: (input: unknown) => input, startProjectClone: async ({ input }: { input: { destinationPath: string } }) => { + state.cloneDestinations.push(input.destinationPath); if (state.startCloneResult) return state.startCloneResult; NodeFS.mkdirSync(input.destinationPath); return AsyncResult.success(undefined); @@ -231,7 +246,7 @@ function cloneAction() { renderScreen(() => AddProjectDestinationScreen({ environmentId: "environment", - remoteUrl: "https://example.com/repo.git", + remoteUrl: "https://example.com/owner/repo.git", repositoryName: "repo", }), ), @@ -275,6 +290,38 @@ describe("clone project permissions", () => { expect(state.projects).toEqual([]); }, ); + + it.each([ + [ + "opens the existing project of that repository instead of cloning into its folder", + "example.com/owner/repo", + false, + ], + ["leaves a folder holding another repository to the clone", "example.com/owner/other", true], + ])("%s", async (_name, canonicalKey, clones) => { + state.scopes.add(AuthOrchestrationOperateScope); + state.cloneDestinations = []; + state.startCloneResult = Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("Destination is not empty"))), + ); + state.existingProjects = [ + { + environmentId: "environment", + id: "existing", + title: "repo", + workspaceRoot: NodePath.join(state.baseDirectory, "repo"), + repositoryIdentity: { canonicalKey }, + }, + ]; + try { + await cloneAction()(); + + expect(state.cloneDestinations.length > 0).toBe(clones); + } finally { + state.existingProjects = []; + state.startCloneResult = null; + } + }); }); function mountProjectFlow( @@ -489,10 +536,10 @@ describe("add project source environment", () => { }); describe("local project navigation", () => { - function setup() { + function setup(baseDirectory = "/tmp/t3-mobile-audit-fixture") { state.navigationState = undefined; state.scopes = new Set([AuthOrchestrationOperateScope]); - state.baseDirectory = "/tmp/t3-mobile-audit-fixture"; + state.baseDirectory = baseDirectory; state.projects = []; state.dispatch.mockReset(); state.alert.mockReset(); @@ -524,6 +571,28 @@ describe("local project navigation", () => { ); }); + it("opens an existing project typed as a home-relative path instead of creating it", async () => { + state.existingProjects = [ + { + environmentId: "environment", + id: "existing", + title: "bike-documents", + workspaceRoot: "/home/tester/projects/bike-documents", + }, + ]; + try { + const { created, projected, submit } = setup("~/projects/bike-documents/"); + state.scopes.add(AuthFilesystemReadScope); + created.resolve(AsyncResult.success(undefined)); + projected.resolve(null); + await submit(); + + expect(state.projects).toEqual([]); + } finally { + state.existingProjects = []; + } + }); + it("does not replace a reopened flow when the previous submission finishes", async () => { const { created, projected, submit } = setup(); const navigation = mountProjectFlow(false); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index ec1d2fe5d949..22a10f32b642 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -389,7 +389,26 @@ function useBrowsePathInput( [browseNavigation], ); - return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; + // Registered roots are absolute and `~` expands on the host, so ask the host for its home. + const resolveHomePath = async (path: string): Promise => { + if ( + !/^~(?:[\\/]|$)/.test(path) || + !environment || + !readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) || + !canPreloadBrowsePath(environmentRuntime?.connectionState) + ) { + return path; + } + const home = await loadBrowsePath({ + environmentId: environment.environmentId, + input: { partialPath: "~" }, + }); + return AsyncResult.isSuccess(home) && home.value + ? `${home.value.parentPath.replace(/[\\/]+$/, "")}${path.slice(1)}` + : path; + }; + + return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath, resolveHomePath }; } function useEnvironmentOptions( @@ -732,16 +751,76 @@ async function draftWithPrompt( return draftId; } -function useCreateProject(environment: EnvironmentOption | null) { +function useOpenExistingProject(environment: EnvironmentOption | null) { const openNewTaskDraft = useOpenNewTaskDraft(); - const navigation = useNavigation(); - const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const updateProject = useAtomCommand(projectEnvironment.update, { label: "project unarchive", }); const projects = useProjects(); - return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { + return async ( + path: string, + originRoute: ReturnType, + clone?: { readonly remoteUrl: string; readonly prompt: string | null }, + ): Promise => { + if (!environment) return false; + const existing = findExistingAddProject({ + projects, + environmentId: environment.environmentId, + path, + }); + if (!existing) return false; + // A folder holding another repository is not this clone; let the clone report it occupied. + if ( + clone && + existing.repositoryIdentity?.canonicalKey !== + sourceControlClients.repositoryKeyFromRemote(clone.remoteUrl) + ) { + return false; + } + const prompt = clone?.prompt; + // Adding an archived project's folder again brings the project back. + const unarchived = + existing.archivedAt == null || + ( + await updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }) + )._tag === "Success"; + Dialog.alert( + "Project already exists", + unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, + ); + openNewTaskDraft( + { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + ...(prompt + ? await draftWithPrompt(existing.environmentId, existing.id, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), + }, + originRoute, + ); + return true; + }; +} + +function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); + const navigation = useNavigation(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const openExistingProject = useOpenExistingProject(environment); + + return async ( + workspaceRoot: string, + originRoute = getActiveRoute(navigation), + lookupPath = workspaceRoot, + ) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -754,33 +833,7 @@ function useCreateProject(environment: EnvironmentOption | null) { return; } - const existing = findExistingAddProject({ - projects, - environmentId: environment.environmentId, - path: workspaceRoot, - }); - if (existing) { - // Adding an archived project's folder again brings the project back. - const unarchived = - existing.archivedAt == null || - ( - await updateProject({ - environmentId: existing.environmentId, - input: { projectId: existing.id, archived: false }, - }) - )._tag === "Success"; - Dialog.alert( - "Project already exists", - unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, - ); - openNewTaskDraft( - { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }, - originRoute, - ); + if (await openExistingProject(lookupPath, originRoute)) { return; } @@ -1316,8 +1369,9 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st environment?.environmentId ?? null, AuthOrchestrationOperateScope, ); + const navigation = useNavigation(); const createProject = useCreateProject(environment); - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput, resolveHomePath } = useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1336,7 +1390,11 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st } setIsSubmitting(true); - const result = await createProject(resolved.path); + const originRoute = getActiveRoute(navigation); + const lookupPath = resolved.path.startsWith("~") + ? await resolveHomePath(resolved.path) + : resolved.path; + const result = await createProject(resolved.path, originRoute, lookupPath); if (result && AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } @@ -1412,11 +1470,9 @@ export function AddProjectDestinationScreen(props: { ? getCloneDirectoryPath(remoteUrl, true, repositoryName) : null; const cloneDirectoryName = ghqDirectoryPath?.split("/").at(-1) ?? repositoryName; - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput( - environment, - cloneDirectoryName, - ghqDirectoryPath ?? repositoryName, - ); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput, resolveHomePath } = + useBrowsePathInput(environment, cloneDirectoryName, ghqDirectoryPath ?? repositoryName); + const openExistingProject = useOpenExistingProject(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1444,6 +1500,15 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); + if ( + await openExistingProject(await resolveHomePath(resolved.path), originRoute, { + remoteUrl, + prompt: prompt ?? null, + }) + ) { + setIsSubmitting(false); + return; + } const title = inferProjectTitleFromPath(resolved.path); const startResult = await startProjectClone({ environmentId: environment.environmentId, diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index 99e518e73973..6abe96ea8445 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -34,6 +34,7 @@ import { import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { + agentAwarenessNotificationsReady, getAgentAwarenessRegistrationStatus, refreshAgentAwarenessRegistration, subscribeAgentAwarenessRegistrationStatus, @@ -110,6 +111,14 @@ function useDeviceRegistered(): boolean { return status === "registered"; } +function useNotificationsReady(): boolean { + return useSyncExternalStore( + subscribeAgentAwarenessRegistrationStatus, + agentAwarenessNotificationsReady, + () => false, + ); +} + export function SettingsNotificationsRouteScreen() { if (!hasCloudPublicConfig()) { return ( @@ -163,6 +172,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { setLiveActivityOverride({ status, basis: liveActivityBasis }); const liveActivityWriteInFlight = useRef(false); const deviceRegistered = useDeviceRegistered(); + const notificationsReady = useNotificationsReady(); const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.liveActivitiesEnabled !== false : true; @@ -196,7 +206,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } if (result.value.type === "granted") { setNotificationStatus("enabled"); - if (getAgentAwarenessRegistrationStatus() === "registered") { + if (agentAwarenessNotificationsReady()) { Dialog.alert("Notifications enabled", "Agent notifications are enabled for this device."); } else { Dialog.alert( @@ -440,7 +450,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } subtitle={agentAwarenessSubtitle} value={ - agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered + agentAwarenessPushAvailable && notificationStatus === "enabled" && notificationsReady } onValueChange={handleDeviceNotificationsChange} /> diff --git a/apps/mobile/src/features/settings/settings-navigation.ts b/apps/mobile/src/features/settings/settings-navigation.ts index b97bc948c0a2..a414d1014a69 100644 --- a/apps/mobile/src/features/settings/settings-navigation.ts +++ b/apps/mobile/src/features/settings/settings-navigation.ts @@ -91,6 +91,24 @@ export function settingsNavigationState, + current: NavigationState | undefined, +) { + const top = link.routes.at(-1); + if (!current || !top || top.state || !isSettingsRoute(top.name)) return undefined; + const page = top.name === "Settings" ? undefined : (top.name as SettingsPageName); + return { + type: "RESET" as const, + payload: settingsNavigationState(current, page, top.params), + target: current.key, + }; +} + /** Opens one Settings page in the Settings tab, from anywhere outside it. */ export function openSettings( navigation: RootStackNavigation, diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx index 5202257e3bc8..ab0ba504ac05 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx @@ -1,7 +1,7 @@ import { applyTerminalAttachStreamEvent, EMPTY_TERMINAL_BUFFER_STATE, - terminalOutputText, + terminalOutputReplay, } from "@t3tools/client-runtime/state/terminal"; import { act, useImperativeHandle, useLayoutEffect, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; @@ -106,7 +106,7 @@ function setup(revision: number | null) { ? null : (revision ?? 0) >= 1 ? "" - : terminalOutputText(state.output) + : terminalOutputReplay(state.output) } isRunning onInput={() => {}} @@ -163,6 +163,24 @@ it.each([null, 1])( } else { expect(mocks.write).not.toHaveBeenCalled(); expect(mocks.initialBuffers).toEqual(["retained", "ined-new"]); + // An older binary recreates its emulator unless the next buffer extends the last one. + // The buffer is full here, so the erase must not push the earlier output out. + let cleared = applyTerminalAttachStreamEvent( + state, + { type: "output", threadId: "thread", terminalId: "term-1", data: "\u001b[?1049h" }, + 16, + ); + await harness.render(cleared); + cleared = applyTerminalAttachStreamEvent( + cleared, + { type: "cleared", threadId: "thread", terminalId: "term-1" }, + 16, + ); + await harness.render(cleared); + expect(mocks.initialBuffers.slice(2)).toEqual([ + "ined-new\u001b[?1049h", + "ined-new\u001b[?1049h\u001b[H\u001b[2J\u001b[3J", + ]); } }, ); diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 1702c2f303de..333cb9c0eade 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -1,4 +1,8 @@ -import type { TerminalGrid, TerminalOutputState } from "@t3tools/client-runtime/state/terminal"; +import { + terminalOutputText, + type TerminalGrid, + type TerminalOutputState, +} from "@t3tools/client-runtime/state/terminal"; import { memo, useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; import { Pressable, @@ -199,7 +203,12 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface({ }); export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { - const buffer = useTerminalSurfaceBuffer(props); + // The text fallback draws escape sequences literally, so it gets displayed text, not the replay. + const buffer = useTerminalSurfaceBuffer( + resolveNativeTerminalSurfaceView() || props.output === null + ? props + : { terminalKey: props.terminalKey, buffer: terminalOutputText(props.output) }, + ); return ; }); diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 229ef86273b8..aee091d9f598 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -299,8 +299,8 @@ function LinkRequestSheet(props: { > - Agents here can then launch, message and wait on threads there. It appears in that - machine's Connections, where it can be revoked. + Agents here can then launch, message and wait on threads there. It appears under + Authorized clients in that machine's Sharing & access, where it can be revoked. {item.url !== undefined && target.kind === "machine" ? ( @@ -375,7 +375,7 @@ function LinkRequestSheet(props: { {useExisting ? ( This environment is already linked to {targetName}. The agent can use that link as it - is; change its access in Settings → Connections. + is; change its access in Settings → Environments. ) : target.kind === "none" ? null : ( <> @@ -394,8 +394,8 @@ function LinkRequestSheet(props: { spellCheck={false} /> - Create a link on {targetName}: Settings → Connections → Create link, then paste - its code here. + Create a link on {targetName}: Settings → Sharing & access → Authorized clients → + Create link, then paste its code here. )} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 1c90d5fa2df1..201204a8a57b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -980,7 +980,10 @@ export function NewTaskDraftScreen(props: { selectedThread={thread} selectedThreadDetailRef={null} contentPresentation={{ kind: "ready" }} - screenTone={connectionTone(connectionState)} + screenTone={connectionTone( + connectionState, + environmentRuntime?.connectionAwaitingApproval === true, + )} connectionError={environmentRuntime?.connectionError ?? null} environmentLabel={selectedEnvironmentLabel} feedbackSubmissions={[]} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b36e408c2a5d..cb9e5eb715dc 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -268,6 +268,7 @@ export interface ThreadDetailScreenProps { readonly activePendingUserInputAnswers: Record> | null; readonly respondingUserInputId: RuntimeRequestId | null; readonly connectionStateLabel: EnvironmentConnectionPhase; + readonly connectionAwaitingApproval?: boolean; readonly threadSyncStatus?: EnvironmentThreadStatus; readonly historyControls?: ThreadFeedHistoryControls; readonly activeThreadBusy: boolean; @@ -765,6 +766,7 @@ function ThreadDetailBody( const connectionStatus = connectionFloatingStatus({ connectionError: connectionRetrying ? retryConnectionError : props.connectionError, connectionState: props.connectionStateLabel, + connectionAwaitingApproval: props.connectionAwaitingApproval === true, environmentLabel: props.environmentLabel, onReconnect: props.onReconnectEnvironment, }); @@ -829,7 +831,11 @@ function ThreadDetailBody( return { kind: "compacting" }; } if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { - return { kind: "working", startedAt: props.activeWorkStartedAt }; + return { + kind: "working", + environmentId: props.environmentId, + startedAt: props.activeWorkStartedAt, + }; } if (pendingBackgroundWork !== null && contentPresentationKind === "ready") { return { diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 8e8c1c4955b2..cffa9134413e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -150,6 +150,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { splitMarkdownDetails } from "../../lib/markdownDetails"; +import { markdownReferenceDefinitions, withReferenceDefinitions } from "../../lib/markdownDocument"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { SelectableMarkdownText, @@ -235,6 +236,8 @@ import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { readEnvironmentScope, usePreparedConnection } from "../../state/session"; import { environmentProjects, projectEnvironment } from "../../state/projects"; import { + agentWorkingDirectories, + findAgentDirectoryMatch, needsWorkspaceBasenameLookup, pickProjectLinkMatch, pickWorkspaceLinkMatch, @@ -255,7 +258,11 @@ import { useModelMentions } from "../../state/model-mentions"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../../state/atom-registry"; -import { environmentThreadShells, threadEnvironment } from "../../state/threads"; +import { + environmentThreadDetails, + environmentThreadShells, + threadEnvironment, +} from "../../state/threads"; import { ThreadWorkflowOpening, useWorkflowOpening } from "./thread-workflow-opening"; import { useAtomCommand } from "../../state/use-atom-command"; import { useV2ItemSupport } from "../../state/v2-item-support"; @@ -1071,10 +1078,17 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { return {props.children}; } +/** The reply a link sits in; a file it cites may be where the agent worked before writing it. */ +type MarkdownLinkReply = Pick; + interface MarkdownLinkHandlers { - readonly onLinkPress: (href: string) => void; + readonly onLinkPress: (href: string, reply?: MarkdownLinkReply) => void; readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; - readonly onFileContextMenuAction: (href: string, actionId: string) => void; + readonly onFileContextMenuAction: ( + href: string, + actionId: string, + reply?: MarkdownLinkReply, + ) => void; readonly onImagePress?: (href: string) => void; readonly resolveImageSource?: MarkdownImageSourceResolver; readonly fileEmbedLabel?: (href: string, standalone: boolean) => string | null; @@ -1145,6 +1159,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly environmentId: EnvironmentId; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; + readonly reply?: MarkdownLinkReply | undefined; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; @@ -1154,6 +1169,9 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { const threadIcons = useThreadChipIcons(props.environmentId, liveMarkdown); const linkHandlers: MarkdownLinkHandlers = { ...props.linkHandlers, + onLinkPress: (href) => props.linkHandlers.onLinkPress(href, props.reply), + onFileContextMenuAction: (href, actionId) => + props.linkHandlers.onFileContextMenuAction(href, actionId, props.reply), linkCustomization: (href) => withChipIcon(props.linkHandlers.linkCustomization?.(href), threadIcons.get(href)), }; @@ -1174,6 +1192,8 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { // Folds split first, so an artifact card inside a fold stays inside it. function AssistantMarkdownBlocks(props: { readonly markdown: string; + /** The reply's reference definitions, passed down by the enclosing fold. */ + readonly definitions?: ReadonlyArray | undefined; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; @@ -1181,15 +1201,20 @@ function AssistantMarkdownBlocks(props: { readonly skills?: ReadonlyArray | undefined; readonly models: ReturnType; }) { - const blocks = useMemo( - () => - splitMarkdownDetails(props.markdown).map((block) => - block.kind === "details" - ? block - : { ...block, segments: splitCodexArtifactTemplateMarkdown(block.markdown) }, - ), - [props.markdown], - ); + const { blocks, definitions } = useMemo(() => { + const blocks = splitMarkdownDetails(props.markdown).map((block) => + block.kind === "details" + ? block + : { ...block, segments: splitCodexArtifactTemplateMarkdown(block.markdown) }, + ); + const [first] = blocks; + // A reply rendered as one document resolves its own references; skip the extra parse. + const whole = blocks.length === 1 && first?.kind === "markdown" && first.segments.length === 1; + return { + blocks, + definitions: props.definitions ?? (whole ? [] : markdownReferenceDefinitions(props.markdown)), + }; + }, [props.markdown, props.definitions]); return blocks.map((block) => { if (block.kind === "details") { return ( @@ -1198,7 +1223,7 @@ function AssistantMarkdownBlocks(props: { summary={block.summary} initiallyOpen={block.open} > - + ); } @@ -1217,7 +1242,9 @@ function AssistantMarkdownBlocks(props: { return ( + + + + + ); + } + if (entry.type === "viewed-images") { const thumbnailWidth = Math.floor((props.contentWidth - VIEWED_IMAGE_GAP) / 2); return ( @@ -2261,6 +2306,7 @@ function renderFeedEntry( environmentId={props.environmentId} markdownStyles={styles} linkHandlers={props.markdownLinkHandlers} + reply={message.projectedItem} onUseArtifactTemplate={props.onUseArtifactTemplate} renderImage={props.renderMarkdownImage} skills={props.skills} @@ -2850,6 +2896,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { linkPath: string, path: string, signal: AbortSignal, + reply: MarkdownLinkReply | undefined, ): Promise => { const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, path); if ( @@ -2881,6 +2928,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { root, entries: result._tag === "Success" ? result.value.entries : [], })); + const metadataOf = (target: string) => + executeAtomQuery( + appAtomRegistry, + projectEnvironment.fileMetadata({ + environmentId: props.environmentId, + input: { path: target }, + }), + queryOptions, + ); const lookup = async () => { const own = await searchRoot(workspaceRoot); let match = pickWorkspaceLinkMatch(relativePath, workspaceRoot, own.entries); @@ -2905,15 +2961,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? null : pickProjectLinkMatch(relativePath, await Promise.all(otherRoots.map(searchRoot))); } + if (match === null && reply !== undefined && !signal.aborted) { + // No project has the file, so it opens where the agent last worked, as written. + const agentRoots = agentWorkingDirectories( + appAtomRegistry.get( + environmentThreadDetails.quietThreadAtom( + scopeThreadRef(props.environmentId, props.threadId), + ), + )?.projection.visibleTurnItems ?? [], + reply, + ).filter((root) => root !== workspaceRoot); + const agentPaths = agentRoots.map((root) => resolveWorkspaceFilePath(root, relativePath)); + if (agentPaths.length === 0) return relativePath; + const [original, ...found] = await Promise.all( + [originalPath, ...agentPaths].map(metadataOf), + ); + if (original?._tag !== "Success" || original.value !== null) return relativePath; + const index = found.findIndex( + (result) => result._tag === "Success" && result.value?.kind === "file", + ); + const literal = agentPaths[index]; + if (literal !== undefined || !needsWorkspaceBasenameLookup(relativePath)) { + return literal ?? relativePath; + } + return ( + (await findAgentDirectoryMatch(relativePath, agentRoots, searchRoot)) ?? relativePath + ); + } if (!match || match === relativePath || signal.aborted) return relativePath; - const metadata = await executeAtomQuery( - appAtomRegistry, - projectEnvironment.fileMetadata({ - environmentId: props.environmentId, - input: { path: originalPath }, - }), - queryOptions, - ); + const metadata = await metadataOf(originalPath); // Another project's file never replaces a thread-local file the search missed. return metadata._tag === "Success" && (needsWorkspaceBasenameLookup(relativePath) && !otherProject @@ -2924,7 +3000,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }; return lookup().catch(() => relativePath); }; - const onMarkdownLinkPress = (href: string, imageEmbed = false) => { + const onMarkdownLinkPress = (href: string, imageEmbed = false, reply?: MarkdownLinkReply) => { markdownLinkRequest.current?.abort(); const request = new AbortController(); markdownLinkRequest.current = request; @@ -2991,7 +3067,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (presentation.kind === "file") { const path = resolveFileLinkPath(props.workspaceRoot, presentation.path); if (path === null) return; - void findFileLinkTarget(presentation.path, path, request.signal).then((target) => { + void findFileLinkTarget(presentation.path, path, request.signal, reply).then((target) => { if (request.signal.aborted) return; void Haptics.selectionAsync(); navigation.navigate("ThreadFile", { @@ -3030,7 +3106,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, standalone, ), - onLinkPress: onMarkdownLinkPress, + onLinkPress: (href, reply) => onMarkdownLinkPress(href, false, reply), linkCustomization: enrichedThreadLinkCustomization, onImagePress: (href) => onMarkdownLinkPress(href, true), resolveImageSource, @@ -3063,7 +3139,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const target = resolveFileChipTarget(href, props.workspaceRoot); return target ? fileChipMenu(target) : undefined; }, - onFileContextMenuAction: (href, actionId) => { + onFileContextMenuAction: (href, actionId, reply) => { const skill = enrichedSkillReference(href, props.skills); if (skill) { const instructionsPath = enrichedSkillInstructionsPath(skill); @@ -3091,7 +3167,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownLinkRequest.current?.abort(); const request = new AbortController(); markdownLinkRequest.current = request; - void findFileLinkTarget(presentation.path, path, request.signal).then((found) => { + void findFileLinkTarget(presentation.path, path, request.signal, reply).then((found) => { if (request.signal.aborted) return; const fullPath = props.workspaceRoot && !isAbsolutePath(found) @@ -3116,7 +3192,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return; } case "open-file": - onMarkdownLinkPress(href); + onMarkdownLinkPress(href, false, reply); return; case "save": shareFileChip(target); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx index 457b7878ea5e..5cf1c9b35329 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -1,3 +1,4 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { createContext, useContext } from "react"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; @@ -43,6 +44,11 @@ export function ThreadMarkdownVideo(props: { visible={visible} onPlaybackStart={props.onPlaybackStart} unavailable={"resource" in source && asset._tag === "Failure"} + unavailableDetail={ + asset._tag === "Failure" && asset.reason === "missing-file-access" + ? MISSING_FILE_ACCESS_MESSAGE + : undefined + } actionsSource={source.actionsSource} borderRadius={props.borderRadius} /> diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e1c51b7f524b..6aa576f984fc 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,10 +1,7 @@ import { useActiveNewTask } from "../../state/active-new-task"; import { ThreadUndoNotice } from "../home/ThreadUndoNotice"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { - pickableProjectGroups, - resolveScratchProjectScopeKey, -} from "@t3tools/client-runtime/state/project-grouping"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { useAppearancePreferences, useAndroidFloatingTabs, @@ -48,7 +45,11 @@ import { requestThreadMessageJump } from "./threadMessageJump"; import { useThreadListSearchMatches } from "./useThreadListSearchMatches"; import { useHomeListOptions } from "../home/home-list-options"; import { buildHomeListFilterMenuItems } from "../home/home-list-filter-menu"; -import { buildHomeProjectScopes } from "../home/homeThreadList"; +import { + buildHomeProjectScopes, + resolveHomeProjectFilterScope, + sortHomeProjectScopes, +} from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; @@ -172,12 +173,6 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { ) ? [options.selectedEnvironmentId] : []; - // A legacy per-environment scratch key resolves here on every render; the stored - // value is only rewritten when the user picks a filter. - const resolvedProjectKey = resolveScratchProjectScopeKey(options.selectedProjectKey, projects, { - sidebarProjectGroupingMode: options.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }); const projectScopes = useMemo( () => buildHomeProjectScopes({ @@ -187,7 +182,14 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { }), [projects, options.selectedEnvironmentId, options.projectGroupingMode], ); - const projectFilterOptions = pickableProjectGroups(projectScopes).map((scope) => ({ + const projectFilterOptions = pickableProjectGroups( + sortHomeProjectScopes({ + scopes: projectScopes, + threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }), + ).map((scope) => ({ key: scope.key, label: scope.title, })); @@ -206,9 +208,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { ), [projectScopes], ); - // An archived project's filter reads as all projects, matching the filter menu. - const selectedProjectScope = - pickableProjectGroups(projectScopes).find((scope) => scope.key === resolvedProjectKey) ?? null; + const selectedProjectScope = resolveHomeProjectFilterScope(projectScopes, projects, options); const threadSearch = useThreadSearch( searchEnvironmentIds, props.searchQuery, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 8f1463ce1bd8..0bed5a25f027 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -990,7 +990,10 @@ function ThreadRouteContent( selectedThread={selectedThreadWithDraftSettings ?? selectedThread} selectedThreadDetailRef={selectedThreadDetailRef} contentPresentation={contentPresentation} - screenTone={connectionTone(routeConnectionState)} + screenTone={connectionTone( + routeConnectionState, + routeEnvironmentRuntime?.connectionAwaitingApproval === true, + )} connectionError={routeConnectionError} environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null} feedbackSubmissions={composer.feedbackSubmissions} @@ -1039,6 +1042,7 @@ function ThreadRouteContent( activePendingUserInputAnswers={requests.activePendingUserInputAnswers} respondingUserInputId={requests.respondingUserInputId} connectionStateLabel={routeConnectionState} + connectionAwaitingApproval={routeEnvironmentRuntime?.connectionAwaitingApproval === true} threadSyncStatus={selectedThreadDetailState.status} historyControls={historyControls} activeThreadBusy={composer.activeThreadBusy} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index a41fc2b3de39..55028a58b7df 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,4 +1,6 @@ +import { toServerClock } from "@t3tools/client-runtime/clock"; import type { SubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents"; +import type { EnvironmentId } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { GlassContainer, GlassView } from "expo-glass-effect"; import { type ReactNode, useEffect, useRef, useState } from "react"; @@ -597,7 +599,12 @@ function FloatingStatusLabel(props: { ); } return ( - + ); } @@ -638,17 +645,21 @@ function StatusLabelRow(props: { } function WorkingDuration(props: { + readonly environmentId: EnvironmentId; readonly startedAt: string; readonly onLayout: (event: LayoutChangeEvent) => void; }) { return ( - + ); } -function WorkingTimer(props: { readonly startedAt: string }) { +function WorkingTimer(props: { + readonly environmentId: EnvironmentId; + readonly startedAt: string; +}) { const nowMs = useVisibleSecondClock(true); return ( - Working {formatWorkingDuration(props.startedAt, nowMs)} + Working {formatWorkingDuration(props.startedAt, toServerClock(props.environmentId, nowMs))} ); } diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index e3c137ebfd25..6beb9cb6cb01 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -1,4 +1,5 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; export interface FloatingStatusAction { readonly label: string; @@ -8,7 +9,7 @@ export interface FloatingStatusAction { } export type FloatingWorkingStatus = - | { readonly kind: "working"; readonly startedAt: string } + | { readonly kind: "working"; readonly environmentId: EnvironmentId; readonly startedAt: string } | { readonly kind: "syncing"; readonly label: string } | { readonly kind: "compacting" } | { @@ -50,6 +51,7 @@ export type FloatingWorkingStatus = export function connectionFloatingStatus(input: { readonly connectionError: string | null; readonly connectionState: EnvironmentConnectionPhase; + readonly connectionAwaitingApproval?: boolean; readonly environmentLabel: string | null; readonly onReconnect: () => void; }): FloatingWorkingStatus | null { @@ -61,6 +63,14 @@ export function connectionFloatingStatus(input: { onPress: input.onReconnect, }); + if (input.connectionState === "reconnecting" && input.connectionAwaitingApproval) { + return { + kind: "connection", + tone: "reconnecting", + label: `Waiting for ${environmentLabel} to approve this device`, + onPress: input.onReconnect, + }; + } switch (input.connectionState) { case "connecting": case "reconnecting": diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index d78dd73b1840..5852206095a8 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -89,7 +89,7 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; -import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; +import { isSameRepository, resolveEnvironmentProjectMatch } from "./new-task-project-selection"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; import { resolveNewThreadRuntimeMode } from "./thread-settings-options"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; @@ -560,11 +560,14 @@ export function NewTaskFlowProvider( replaceComposerDraftAttachments(selectedProjectDraftKey, []); }; - const carryDraftContentTo = (project: EnvironmentProject): string => { + const carryDraftContentTo = ( + project: EnvironmentProject, + options?: Parameters[2], + ): string => { const target = { environmentId: project.environmentId, projectId: project.id }; const current = readFlow().draftKey; if (current !== null && isNewTaskDraftKey(current)) { - retargetNewTaskDraft(current, target); + retargetNewTaskDraft(current, target, options); return current; } return createNewTaskDraft(target); @@ -584,7 +587,12 @@ export function NewTaskFlowProvider( selectedProject, ); updateFlow({ - draftKey: match ? carryDraftContentTo(match) : readFlow().draftKey, + // A name or title match may be another repository, which must not receive the PR checkout. + draftKey: match + ? carryDraftContentTo(match, { + keepPullRequestWorkspace: isSameRepository(match, selectedProject), + }) + : readFlow().draftKey, environmentId, projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null, }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 99cf9e7d37ef..82533a3c91e7 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -5,7 +5,9 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { getProjectScopeSelectionTarget, + isSameRepository, newTaskDestination, + newTaskFilteredProject, resolveDraftProjectSelection, resolveEnvironmentProjectMatch, resolveNewTaskScratchOption, @@ -77,6 +79,37 @@ describe("getProjectScopeSelectionTarget", () => { }); }); +describe("newTaskFilteredProject", () => { + it("never starts in an archived copy of the filtered project", () => { + const mac = { ...makeProject("t3code", "mac"), archivedAt: "2026-01-01T00:00:00.000Z" }; + const server = makeProject("t3code", "server"); + expect( + newTaskFilteredProject( + makeScope([mac, server]), + null, + new Set([mac.environmentId, server.environmentId]), + { environmentId: "mac", projectId: "t3code" }, + ), + ).toEqual({ environmentId: "server", projectId: "t3code" }); + }); + + it("prefers the remembered copy of the filtered project", () => { + const mac = makeProject("t3code", "mac"); + const server = makeProject("t3code", "server"); + const connected = new Set([mac.environmentId, server.environmentId]); + const filtered = (last?: { readonly environmentId: string; readonly projectId: string }) => + newTaskFilteredProject(makeScope([mac, server]), null, connected, last); + expect(filtered({ environmentId: "server", projectId: "t3code" })).toEqual({ + environmentId: "server", + projectId: "t3code", + }); + expect(filtered({ environmentId: "server", projectId: "other" })).toEqual({ + environmentId: "mac", + projectId: "t3code", + }); + }); +}); + describe("resolveEnvironmentProjectMatch", () => { it("follows the same repository onto the target machine", () => { const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); @@ -85,6 +118,7 @@ describe("resolveEnvironmentProjectMatch", () => { makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }), ]; expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); + expect(isSameRepository(target[1]!, selected)).toBe(true); }); it("falls back to workspace basename, then title, for unindexed projects", () => { @@ -94,6 +128,7 @@ describe("resolveEnvironmentProjectMatch", () => { makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), ]; expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); + expect(isSameRepository(byBasename[1]!, selected)).toBe(false); const byTitle = [ makeProject("other", "server"), @@ -115,6 +150,7 @@ describe("resolveEnvironmentProjectMatch", () => { const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); + expect(isSameRepository(fork, selected)).toBe(false); }); }); @@ -173,6 +209,7 @@ describe("newTaskDestination", () => { readonly useLastProject: boolean | undefined; readonly projects: ReadonlyArray; readonly current?: EnvironmentProject; + readonly filtered?: EnvironmentProject; readonly last?: { readonly environmentId: string; readonly projectId: string }; }) { return newTaskDestination({ @@ -183,17 +220,22 @@ describe("newTaskDestination", () => { environmentId: options.current.environmentId, projectId: options.current.id, }, + filteredProject: options.filtered && { + environmentId: options.filtered.environmentId, + projectId: options.filtered.id, + }, lastProject: options.last, }); } - it("starts in the current thread's project ahead of the remembered one", () => { + it("starts in the current thread's project ahead of the filtered and remembered ones", () => { const projects = [makeProject("remembered"), makeProject("current", "other-environment")]; expect( destination({ useLastProject: true, projects, current: projects[1], + filtered: projects[0], last: { environmentId: "environment", projectId: "remembered" }, }), ).toEqual({ @@ -201,6 +243,20 @@ describe("newTaskDestination", () => { }); }); + it("starts in the thread list's filtered project ahead of the remembered one", () => { + const projects = [makeProject("bravo"), makeProject("alpha")]; + expect( + destination({ + useLastProject: true, + projects, + filtered: projects[1], + last: { environmentId: "environment", projectId: "bravo" }, + }), + ).toEqual({ + draft: { requestId: "request", environmentId: "environment", projectId: "alpha" }, + }); + }); + it("opens the picker for an archived last project, but keeps an archived current one", () => { const archived = { ...makeProject("archived"), archivedAt: "2026-01-01T00:00:00.000Z" }; expect( @@ -262,6 +318,7 @@ describe("newTaskDestination", () => { useLastProject: false, projects: [only], current: only, + filtered: only, last: { environmentId: "environment", projectId: "only" }, }), ).toEqual(picker); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 93c8f1d5f51d..2fa21eba1e23 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -117,6 +117,19 @@ export function resolveEnvironmentProjectMatch( ); } +/** Both projects have a known repository identity, and it is the same one. */ +export function isSameRepository( + left: EnvironmentProject | null, + right: EnvironmentProject | null, +): boolean { + return ( + left?.repositoryIdentity != null && + right?.repositoryIdentity != null && + repositoryGroupingKeyOf(left.repositoryIdentity) === + repositoryGroupingKeyOf(right.repositoryIdentity) + ); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, @@ -147,11 +160,35 @@ export function resolveDraftProjectSelection( type ProjectRef = { readonly environmentId: string; readonly projectId: string }; +/** The live copy of the thread list's filtered project a new thread starts in: the remembered one if it is a copy. */ +export function newTaskFilteredProject( + scope: HomeProjectScope, + preferredEnvironmentId: EnvironmentId | null, + connectedEnvironmentIds: ReadonlySet, + lastProject: ProjectRef | undefined, +): ProjectRef | undefined { + const live = scope.projects.filter((project) => project.archivedAt == null); + if (live.length === 0) return undefined; + const project = + live.find( + (candidate) => + candidate.environmentId === lastProject?.environmentId && + candidate.id === lastProject.projectId, + ) ?? + getProjectScopeSelectionTarget( + { ...scope, projects: live }, + preferredEnvironmentId, + connectedEnvironmentIds, + ); + return { environmentId: project.environmentId, projectId: project.id }; +} + export function newTaskDestination(options: { readonly projects: ReadonlyArray; readonly requestId: string; readonly useLastProject: boolean | undefined; readonly currentProject: ProjectRef | undefined; + readonly filteredProject: ProjectRef | undefined; readonly lastProject: ProjectRef | undefined; }) { const find = (ref: ProjectRef | undefined) => @@ -164,7 +201,9 @@ export function newTaskDestination(options: { const project = options.useLastProject === false ? undefined - : (find(options.currentProject) ?? (last?.archivedAt == null ? last : undefined)); + : (find(options.currentProject) ?? + find(options.filteredProject) ?? + (last?.archivedAt == null ? last : undefined)); return project ? { draft: { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index d724d8056e95..b6bf0c98e349 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -25,7 +25,12 @@ import { formatWorkingDurationLabel, resolveThreadWorkingStartedAt, } from "@t3tools/client-runtime/state/models"; -import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type EnvironmentMachineKind, +} from "@t3tools/contracts"; +import { toServerClock } from "@t3tools/client-runtime/clock"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; @@ -153,6 +158,7 @@ const TABULAR_DURATION_STYLE = { } as const; function ThreadListV2Duration(props: { + readonly environmentId: EnvironmentId; readonly startedAt: string | null; readonly leadingSpace: boolean; }) { @@ -163,7 +169,7 @@ function ThreadListV2Duration(props: { <> {props.leadingSpace ? " " : null} - {formatWorkingDurationLabel(nowMs - startedAtMs)} + {formatWorkingDurationLabel(toServerClock(props.environmentId, nowMs) - startedAtMs)} ); @@ -1254,11 +1260,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {statusLabel.label} {status === "working" ? ( ) : status === "waiting" ? ( diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1e66bf4ee2c8..a77f7089c97b 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,3 +1,4 @@ +import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; import * as Dialog from "../../components/ui/Dialog"; import type { EnvironmentId, @@ -358,7 +359,7 @@ export function useComposerCommandMenu({ if (trigger.kind === "pull-request") { return pullRequestSearch.entries.map((entry) => ({ - id: `pr:${entry.projectId}:${entry.repository}:${entry.number}`, + id: `pr:${entry.projectId}:${threadPullRequestKeyOf(entry)}`, type: "pull-request", pullRequest: { number: entry.number, diff --git a/apps/mobile/src/lib/markdownDetails.test.ts b/apps/mobile/src/lib/markdownDetails.test.ts index f4eae4487252..c90d89838f4c 100644 --- a/apps/mobile/src/lib/markdownDetails.test.ts +++ b/apps/mobile/src/lib/markdownDetails.test.ts @@ -1,6 +1,33 @@ import { describe, expect, it } from "vite-plus/test"; import { splitMarkdownDetails } from "./markdownDetails"; +import { + markdownForNativeParsing, + markdownReferenceDefinitions, + withReferenceDefinitions, +} from "./markdownDocument"; +import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; + +type NativeNode = Awaited>; + +/** Where the native parser sends each link and image in one rendered fragment of a reply. */ +async function referenceDestinations(reply: string, pick: number) { + const segment = splitMarkdownDetails(reply)[pick]; + const fragment = segment?.kind === "details" ? segment.body : (segment?.markdown ?? ""); + const urls: Array = []; + const visit = (node: NativeNode) => { + if (node.type === "Link" || node.type === "Image") urls.push(node.attributes?.url); + node.children?.forEach(visit); + }; + visit( + await parseMarkdown( + markdownForNativeParsing( + withReferenceDefinitions(fragment, markdownReferenceDefinitions(reply)), + ), + ), + ); + return urls; +} describe("splitMarkdownDetails", () => { // regression: #28059 mobile chat showed every

fold open @@ -78,3 +105,57 @@ describe("splitMarkdownDetails", () => { ).toMatchObject([{ kind: "details", open: false }]); }); }); + +describe("withReferenceDefinitions", () => { + it("resolves a fragment's references from the whole reply, its own definitions first", async () => { + const fold = "\n\n
Aa
\n\n"; + for (const [reply, pick, expected] of [ + // Into a fold, with an escaped label and a Windows destination kept as written. + [ + "
\nA\n\n[config][c] ![B][a\\]b]\n\n
\n\n[c]: C:\\work\\.t3\\settings.json\n[a\\]b]: https://example.com/a", + 0, + ["C:\\work\\.t3\\settings.json", "https://example.com/a"], + ], + // Out of a fold. + [ + "See [d].\n\n
\nA\n\n[d]: https://example.com\n\n
", + 0, + ["https://example.com"], + ], + // A quoted definition that wraps onto continuation lines. + [`See [d].${fold}> [d]:\n> https://example.com\n> "Title"`, 0, ["https://example.com"]], + // A definition the compact HTML block hides from the reply's parse, with a label that wraps. + [ + "See [the\n doc].\n\n
Aa
\n[The Doc]: https://example.com", + 0, + ["https://example.com"], + ], + // A literal ">" in a wrapped label is part of the label, not a quote marker. + [ + `[part rest] and [part > rest]${fold}[part\n > rest]: https://example.com/a\n\n[part rest]: https://example.com/b`, + 0, + ["https://example.com/b", "https://example.com/a"], + ], + // A fragment's own definition wins, however the parser equates the two labels. + [ + "[ x ]: https://outside.example\n\n
\nS\n\nSee [x].\n\n[X]: https://inside.example\n\n
", + 1, + ["https://inside.example"], + ], + [ + "[SS]: https://outside.example\n\n
\nS\n\nSee [SS].\n\n[\u00df]: https://inside.example\n\n
", + 1, + ["https://inside.example"], + ], + ] as const) { + expect(await referenceDestinations(reply, pick)).toEqual(expected); + } + }); + + it("leaves a fragment with nothing to resolve as written", () => { + expect(withReferenceDefinitions("See [d].", [])).toBe("See [d]."); + expect(withReferenceDefinitions("Plain text.", ["[d]: https://example.com"])).toBe( + "Plain text.", + ); + }); +}); diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index dceebd418b1b..ddeca4b2d210 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -284,6 +284,41 @@ export function markdownWithHtml(markdown: string): string { return applyMarkdownEdits(markdown, edits); } +/** Reference definitions as authored, one line each, so a Windows destination keeps its backslashes. */ +export function markdownReferenceDefinitions(markdown: string): string[] { + if (!markdown.includes("]:")) return []; + // Flattened as the renderer does it: a raw HTML block hides the definitions inside it. + const flattened = markdownWithHtml(markdown); + const definitions: string[] = []; + const visit = (node: MarkdownNode, quotes: number) => { + if (node.type === "definition") { + // A wrapped definition drops its indent and the enclosing quotes' markers, not a literal ">". + const wrap = new RegExp(`(?:\\r\\n|\\r|\\n)(?:[ \\t]*>){0,${quotes}}[ \\t]*`, "g"); + definitions.push( + flattened.slice(node.position?.start.offset, node.position?.end.offset).replace(wrap, " "), + ); + } + if (!("children" in node)) return; + for (const child of node.children) visit(child, quotes + (node.type === "blockquote" ? 1 : 0)); + }; + visit(parser.parse(flattened), 0); + return definitions; +} + +/** + * A reply split into folds and cards renders each fragment as its own document, so each one + * carries the reply's definitions. The first definition of a label wins, so the fragment's own + * lead: the renderer then prefers them by its own label matching, with none reimplemented here. + */ +export function withReferenceDefinitions( + fragment: string, + replyDefinitions: ReadonlyArray, +): string { + if (replyDefinitions.length === 0 || !fragment.includes("[")) return fragment; + const definitions = new Set([...markdownReferenceDefinitions(fragment), ...replyDefinitions]); + return `${[...definitions].join("\n")}\n\n${fragment}`; +} + // Start only at the beginning of a backtick run to avoid retrying every suffix. const INLINE_CODE_SPAN = /(? { phase === "final_answer", ); } + const confirmed = { ...answer, phase: "final_answer" as const }; + const continued = buildThreadFeed( + [user, confirmed, passed, final].map((item, index) => projected(item, index)), + ); + expect( + deriveThreadFeedPresentation(continued, null, new Set()).some( + (row) => row.type === "message" && row.message.id === answer.messageId, + ), + ).toBe(true); + expect(rows([user, confirmed, passed, final], "zen")).toEqual(["user", "answer", "final"]); expect(rows([user, steer, step, passed, final], "zen")).toEqual(["user", "steer", "final"]); expect(rows([user, steer, answer, step], "zen", running)).toEqual([ "user", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 67d96c0f8845..5b9f07ed7733 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -6,7 +6,10 @@ import { linkRequestTargetName } from "@t3tools/client-runtime/link-request"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; -import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + isLiveSubagentTurnItem, + waitingOnSubagentsLabel, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { turnItemDetailRevision, @@ -73,6 +76,7 @@ import type { McpAppReference } from "@t3tools/shared/mcpApp"; import { compactDynamicToolOutput, htmlRenderFromToolItem, + sentMessageFromToolItem, mcpAppFromToolItem, } from "@t3tools/shared/toolOutput"; import * as DateTime from "effect/DateTime"; @@ -182,6 +186,13 @@ type RawThreadFeedEntry = readonly runId: RunId | null; readonly render: HtmlRenderReference; } + | { + readonly type: "sent-message"; + readonly id: string; + readonly createdAt: string; + readonly runId: RunId | null; + readonly text: string; + } | { readonly type: "mcp-app"; readonly id: string; @@ -200,7 +211,7 @@ export type ThreadFeedEntry = ThreadFeedEntryContent & { }; type ThreadFeedEntryContent = - | Extract + | Extract | { readonly type: "activity-group"; readonly id: string; @@ -1098,26 +1109,25 @@ function feedEntryFoldRunId(entry: ThreadFeedEntry, runlessKey: RunId | null): R if (entry.type === "message") { return entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) : null; } - return entry.type === "activity-group" || entry.type === "html-render" || entry.type === "mcp-app" + return entry.type === "activity-group" || + entry.type === "html-render" || + entry.type === "sent-message" || + entry.type === "mcp-app" ? (entry.runId ?? runlessKey) : null; } -function answersBeforeSteers(feed: ReadonlyArray): ReadonlySet { +function finalAnswerIds(feed: ReadonlyArray): ReadonlySet { const ids = new Set(); - let previous: ThreadFeedEntry | undefined; for (const entry of feed) { if ( entry.type === "message" && - isSteerMessage(entry.message) && - previous?.type === "message" && - previous.message.role === "assistant" && - previous.message.projectedItem?.item.type === "assistant_message" && - previous.message.projectedItem.item.phase === "final_answer" + entry.message.role === "assistant" && + entry.message.projectedItem?.item.type === "assistant_message" && + entry.message.projectedItem.item.phase === "final_answer" ) { - ids.add(previous.message.id); + ids.add(entry.message.id); } - previous = entry; } return ids; } @@ -1129,7 +1139,7 @@ function deriveThreadFeedRunFolds( feedFailedRunIds: ReadonlySet, ): ReadonlyMap { const terminalAssistantMessageIdByRun = new Map(); - const retainedAnswerIds = answersBeforeSteers(feed); + const retainedAnswerIds = finalAnswerIds(feed); const interruptedRunIds = new Set(); const failedRunIds = new Set(feedFailedRunIds); const promptIndexByRunId = new Map(); @@ -1231,6 +1241,7 @@ function deriveThreadFeedRunFolds( entry.id !== terminalAssistantId && !(entry.type === "message" && retainedAnswerIds.has(entry.message.id)) && entry.type !== "html-render" && + entry.type !== "sent-message" && entry.type !== "mcp-app" && entry.id !== visibleCompactionId && !( @@ -1523,7 +1534,7 @@ function zenAnswerMessageIds( ): ReadonlySet { const replies: Array<{ readonly message: ThreadFeedMessage; readonly index: number }> = []; const lastReplyByResponse = new Map(); - const steerAnswers = new Set(answersBeforeSteers(feed)); + const keptAnswers = new Set(finalAnswerIds(feed)); let userMessages = 0; let awaitingReply = false; let candidate: MessageId | null = null; @@ -1538,7 +1549,7 @@ function zenAnswerMessageIds( } const reply = entry.type === "message" && entry.message.role === "assistant" ? entry.message : null; - if (candidate !== null && reply !== null) steerAnswers.add(candidate); + if (candidate !== null && reply !== null) keptAnswers.add(candidate); candidate = null; if (answering && startsResponse(entry)) answeringFrom = index + 1; if (entry.type === "message" && entry.message.role === "user") { @@ -1558,7 +1569,7 @@ function zenAnswerMessageIds( replies .filter( ({ message, index }) => - steerAnswers.has(message.id) || + keptAnswers.has(message.id) || (terminal.has(message.id) && index < answeringFrom && (answeringRunId === null || message.runId !== answeringRunId)), @@ -1809,11 +1820,12 @@ export function deriveThreadFeedPresentation( row.activities[0]?.projectedItem.item.status === "running"), ) ) { + const waitingOn = activeRunId === null ? 0 : runningSubagentsAtRunTail(feed, activeRunId); result.push( thinkingRow( activeWorkStartedAt, activeRunId, - zen ? liveAnswerPreview(feed) : null, + waitingOn > 0 ? waitingOnSubagentsLabel(waitingOn) : zen ? liveAnswerPreview(feed) : null, latestRun?.providerStartingLabel, ), ); @@ -1920,6 +1932,19 @@ function isWorkLogFeedRow(row: ThreadFeedEntry | undefined): boolean { const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; +/** Reads the feed before tool visibility filters it, so hidden newer parent work still counts. */ +function runningSubagentsAtRunTail(feed: ReadonlyArray, runId: RunId): number { + let running = 0; + for (let index = feed.length - 1; index >= 0; index -= 1) { + const entry = feed[index]!; + if (entry.type !== "activity-group" || entry.runId !== runId) break; + const items = entry.activities.map((activity) => activity.projectedItem.item); + if (items.some((item) => item.type !== "subagent")) break; + running += items.filter(isLiveSubagentTurnItem).length; + } + return running; +} + function thinkingRow( createdAt: string, runId: RunId | null, @@ -2433,6 +2458,23 @@ export function buildThreadFeed( entryRows.push(index); continue; } + const sentMessage = + item.type === "dynamic_tool" && item.status === "completed" + ? sentMessageFromToolItem(item) + : undefined; + if (sentMessage !== undefined) { + const entry: RawThreadFeedEntry = { + type: "sent-message", + id: `sent-message:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, + createdAt, + runId: item.runId, + text: sentMessage, + }; + projectedEntriesCache.set(row, { attemptId, entries: [entry] }); + entries.push(entry); + entryRows.push(index); + continue; + } if (item.type === "user_message" || item.type === "assistant_message") { const updatedAt = DateTime.formatIso(item.updatedAt); const entry: RawThreadFeedEntry = { diff --git a/apps/mobile/src/state/auth.ts b/apps/mobile/src/state/auth.ts new file mode 100644 index 000000000000..835dee7f7837 --- /dev/null +++ b/apps/mobile/src/state/auth.ts @@ -0,0 +1,5 @@ +import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/queries.filesystem.test.ts b/apps/mobile/src/state/queries.filesystem.test.ts index 8737caccf14b..46d4fecc9e4d 100644 --- a/apps/mobile/src/state/queries.filesystem.test.ts +++ b/apps/mobile/src/state/queries.filesystem.test.ts @@ -7,6 +7,7 @@ const state = vi.hoisted(() => ({ phase: "connected" as "connected" | "offline", sessionAtom: {}, searchAtom: {}, + searchRequests: vi.fn(), })); vi.mock("react", async (importOriginal) => ({ @@ -27,7 +28,12 @@ vi.mock("./presentation", () => ({ })); vi.mock("./pull-requests", () => ({ composerPullRequests: {} })); vi.mock("./projects", () => ({ - projectEnvironment: { searchEntries: () => state.searchAtom }, + projectEnvironment: { + searchEntries: (target: unknown) => { + state.searchRequests(target); + return state.searchAtom; + }, + }, })); vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); @@ -59,6 +65,7 @@ beforeEach(() => { state.session = null; state.sessionError = null; state.phase = "connected"; + state.searchRequests.mockClear(); }); it("keeps a file search pending until its grant loads, then shows matches", () => { @@ -74,3 +81,12 @@ it("keeps a file search pending until its grant loads, then shows matches", () = isPending: false, }); }); + +it("searches workspace entries for a bare @", () => { + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + useComposerPathSearch({ ...target, query: "" }); + expect(state.searchRequests).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { cwd: target.cwd, query: "", limit: 20 }, + }); +}); diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts index 12f36dfe2e24..dba11b2e56ea 100644 --- a/apps/mobile/src/state/queries.test.ts +++ b/apps/mobile/src/state/queries.test.ts @@ -1,8 +1,10 @@ +import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; import { expect, it } from "@effect/vitest"; it("keeps an older exact PR in the mobile menu ahead of twenty newer substring matches", () => { const exact = { + host: "github.com", number: 42, projectId: "project", repository: "example/repo", @@ -14,6 +16,7 @@ it("keeps an older exact PR in the mobile menu ahead of twenty newer substring m updatedAt: "2026-01-01", })); const matches = filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries: [exact, ...recent, exact], projectId: exact.projectId, repository: exact.repository, diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 837d77ce851c..38890d7d5750 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,3 +1,7 @@ +import { + legacyThreadPullRequestKey, + threadPullRequestKeyOf, +} from "@t3tools/client-runtime/thread-pull-requests"; import { composerPullRequestsFromLinks, filterComposerPullRequestMatches, @@ -148,11 +152,16 @@ export function useComposerPullRequestSearch(input: { }) : null, ); + // Only the host: the URL's full repository path would not match the project's selector. + const exactEntry = exact.data + ? { ...exact.data, host: legacyThreadPullRequestKey(exact.data).host } + : null; const entries = (() => { if (input.query === null || input.projectId === null || input.repository === null) return []; if (/^\d*$/.test(input.query)) { return filterComposerPullRequestMatches({ - entries: ready ? [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])] : [], + keyOf: threadPullRequestKeyOf, + entries: ready ? [...(exactEntry ? [exactEntry] : []), ...(list.data?.entries ?? [])] : [], linked, projectId: input.projectId, repository: input.repository, @@ -162,6 +171,7 @@ export function useComposerPullRequestSearch(input: { } if (!ready) { return withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: [], linked, query: input.query, @@ -169,7 +179,7 @@ export function useComposerPullRequestSearch(input: { }); } const words = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean); - const found = [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])].filter( + const found = [...(exactEntry ? [exactEntry] : []), ...(list.data?.entries ?? [])].filter( (entry) => entry.projectId === input.projectId && entry.repository.toLowerCase() === input.repository?.toLowerCase() && @@ -177,9 +187,13 @@ export function useComposerPullRequestSearch(input: { `${entry.title} ${entry.headBranch} ${entry.baseBranch}`.toLowerCase().includes(word), ), ); - const unique = new Map(); - for (const entry of found) if (!unique.has(entry.number)) unique.set(entry.number, entry); + const unique = new Map(); + for (const entry of found) { + const key = threadPullRequestKeyOf(entry); + if (!unique.has(key)) unique.set(key, entry); + } return withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: [...unique.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), ), @@ -412,7 +426,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { const searchTarget = debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && - debouncedTarget.query.length > 0 + debouncedTarget.query !== null ? { environmentId: debouncedTarget.environmentId, input: { diff --git a/apps/mobile/src/state/queryTargets.ts b/apps/mobile/src/state/queryTargets.ts index a52da3fc1348..69237d663826 100644 --- a/apps/mobile/src/state/queryTargets.ts +++ b/apps/mobile/src/state/queryTargets.ts @@ -8,8 +8,8 @@ export interface CheckpointDiffTarget { readonly ignoreWhitespace: boolean; } -export function normalizeComposerPathSearchQuery(query: string | null): string { - return query?.trim() ?? ""; +export function normalizeComposerPathSearchQuery(query: string | null): string | null { + return query?.trim() ?? null; } export function buildCheckpointDiffTargets(target: CheckpointDiffTarget) { diff --git a/apps/mobile/src/state/remote-environment-projections.ts b/apps/mobile/src/state/remote-environment-projections.ts index c70acce6d932..924648e004c1 100644 --- a/apps/mobile/src/state/remote-environment-projections.ts +++ b/apps/mobile/src/state/remote-environment-projections.ts @@ -102,6 +102,7 @@ export function createRemoteEnvironmentProjectionAtoms(input: { connection.phase === previousConnection?.phase && connection.error === previousConnection?.error && connection.traceId === previousConnection?.traceId && + connection.awaitingApproval === previousConnection?.awaitingApproval && serverConfig === previousServerConfig ) { return previous; @@ -113,6 +114,7 @@ export function createRemoteEnvironmentProjectionAtoms(input: { connectionState: connection.phase, connectionError: connection.error, connectionErrorTraceId: connection.traceId, + ...(connection.awaitingApproval ? { connectionAwaitingApproval: true as const } : {}), serverConfig, }; return previous; diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts index ebb825522f0e..8f76efffbe2c 100644 --- a/apps/mobile/src/state/remote-runtime-types.ts +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -5,6 +5,8 @@ export interface EnvironmentRuntimeState { readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; + /** Reconnecting only because the host has not approved this device yet. */ + readonly connectionAwaitingApproval?: true; readonly serverConfig: ServerConfig | null; } diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 309bb6a2b0a7..0d7bfdc3e8c7 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -10,7 +10,7 @@ import { import * as Cause from "effect/Cause"; import { AsyncResult, type Atom } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { threadOutboxRetryDelayMs, type QueuedThreadMessage } from "./thread-outbox-model"; const state = vi.hoisted(() => ({ draftText: "Keep this draft separate", @@ -501,6 +501,108 @@ describe("queued task operation access", () => { ); }); +describe("queued task whose launch reply is lost", () => { + const queuedTask = (creation: Partial> = {}) => + message({ + threadId: ThreadId.make("new-thread"), + text: "My dictated idea", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + creation: { + projectId: ProjectId.make("project"), + projectCwd: "/repo", + workspaceMode: "local", + branch: null, + worktreePath: null, + ...creation, + }, + }); + + /** The server creates the thread, but the launch reply is lost; the thread then shows up. */ + async function loseLaunchReply(queued: QueuedThreadMessage, edit?: string): Promise { + setOperateGrant("secondary", true); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + state.cleanups.push(() => vi.useRealTimers()); + await state.manager.enqueue(queued); + state.start.mockImplementationOnce(async () => { + if (edit !== undefined) await state.manager.update({ ...queued, text: edit }); + return AsyncResult.failure(Cause.fail(new Error("Socket is not connected"))); + }); + await runDrain(); + expect(remaining()).toHaveLength(1); + appAtomRegistry.set(state.threadShells, [state.thread, { ...state.thread, id: "new-thread" }]); + } + + async function fireRetry(): Promise { + const settled = Promise.withResolvers(); + const release = appAtomRegistry.subscribe(dispatchingQueuedMessageIdAtom, (id) => { + if (id === null) settled.resolve(); + }); + vi.advanceTimersByTime(threadOutboxRetryDelayMs(1)); + await settled.promise.finally(release); + } + + it("resends an unedited launch instead of handing its text back", async () => { + await loseLaunchReply(queuedTask()); + await fireRetry(); + + expect(state.draftText).toBe("Keep this draft separate"); + // The resend reuses the launch's command and message ids, so the server replays it. + const [first, resent] = state.start.mock.calls.map(([call]) => call.input); + expect(state.start).toHaveBeenCalledTimes(2); + expect(resent.commandId).toBe(first.commandId); + expect(resent.message.messageId).toBe(first.message.messageId); + expect(remaining()).toEqual([]); + }); + + it("hands back a task edited while its launch was in flight", async () => { + await loseLaunchReply(queuedTask(), "Edited idea"); + await fireRetry(); + + expect(state.draftText).toBe("Keep this draft separate\nEdited idea"); + expect(state.start).toHaveBeenCalledOnce(); + expect(remaining()).toEqual([]); + }); + + it("holds the resend while operation access is missing", async () => { + await loseLaunchReply(queuedTask()); + setOperateGrant("secondary", false); + vi.advanceTimersByTime(threadOutboxRetryDelayMs(1)); + expect(appAtomRegistry.get(dispatchingQueuedMessageIdAtom)).toBeNull(); + expect(state.start).toHaveBeenCalledOnce(); + expect(remaining()).toHaveLength(1); + + setOperateGrant("secondary", true); + await runDrain(); + expect(state.start).toHaveBeenCalledTimes(2); + expect(state.draftText).toBe("Keep this draft separate"); + expect(remaining()).toEqual([]); + }); + + it("resends a pull request task in its launched worktree without checking out again", async () => { + state.checkout.mockResolvedValueOnce( + AsyncResult.success({ + branch: "pr-123", + worktreePath: "/repo/.t3/worktrees/pr-123", + isOnPullRequestHead: true, + }), + ); + await loseLaunchReply( + queuedTask({ + workspaceMode: "worktree", + branch: "fork/feature", + pullRequestReference: "https://github.com/acme/repo/pull/123", + }), + ); + await fireRetry(); + + expect(state.checkout).toHaveBeenCalledOnce(); + const [first, resent] = state.start.mock.calls.map(([call]) => call.input); + expect(resent.bootstrap.createThread.worktreePath).toBe("/repo/.t3/worktrees/pr-123"); + expect(resent.bootstrap).toEqual(first.bootstrap); + expect(remaining()).toEqual([]); + }); +}); + describe("compaction before a queued send", () => { const compactTurn = { commandId: "command:compact", diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index c64215ed26cd..ed9c33719a4d 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -694,6 +694,12 @@ export function createThreadOutboxDelivery(): { // Existing-thread turns the server accepted but whose outbox removal has // not landed; the next pass retries only the removal, never the send. const acknowledgedExistingThreadMessageIds = new Set(); + // Revision and workspace each new task's launch carried. Launch replays by command id, so an + // unedited task whose reply was lost is resent in that workspace, not handed back. + const launchedCreations = new Map< + MessageId, + { readonly revision: number; readonly workspace: QueuedThreadCreation } + >(); // Targets the shell may have left out hold their thread family until they leave the queue, so // the row stays readable through dispatch. const familyMounts = new Map void>(); @@ -1064,7 +1070,10 @@ export function createThreadOutboxDelivery(): { ); if (!hasAccess()) return true; let workspace = creation; - if (creation.pullRequestReference) { + const launched = launchedCreations.get(persistedMessage.messageId); + if (launched?.revision === deliveryRevision) { + workspace = launched.workspace; + } else if (creation.pullRequestReference) { const checkout = await runDeliveryCommand(gitEnvironment.preparePullRequestThread, { environmentId: queuedMessage.environmentId, input: { cwd: projectCwd, reference: creation.pullRequestReference, mode: "worktree" }, @@ -1094,6 +1103,7 @@ export function createThreadOutboxDelivery(): { startFromOrigin: false, }; } + launchedCreations.set(persistedMessage.messageId, { revision: deliveryRevision, workspace }); const deliveryResult = await runDeliveryCommand(threadEnvironment.startTurn, { environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -1196,6 +1206,11 @@ export function createThreadOutboxDelivery(): { acknowledgedExistingThreadMessageIds.delete(messageId); } } + for (const messageId of launchedCreations.keys()) { + if (!queuedMessageIds.has(messageId)) { + launchedCreations.delete(messageId); + } + } for (const [messageId, release] of familyMounts) { if (!queuedMessageIds.has(messageId)) { release(); @@ -1290,9 +1305,13 @@ export function createThreadOutboxDelivery(): { ); } } + const resendLaunch = + creation !== undefined && + launchedCreations.get(messageId)?.revision === threadOutboxRevision(messageId); const deliveryAction = resolveThreadOutboxDeliveryAction({ isCreation: creation !== undefined, - threadExists: thread !== undefined, + // A resend goes through the same gates as the first launch. + threadExists: thread !== undefined && !resendLaunch, shellStatus, environmentConnected: environment?.connectionState === "connected", ...(familyRead === undefined ? {} : { familyRead }), @@ -1387,7 +1406,7 @@ export function createThreadOutboxDelivery(): { ); const liveDeliveryAction = resolveThreadOutboxDeliveryAction({ isCreation: creation !== undefined, - threadExists: liveThread !== undefined, + threadExists: liveThread !== undefined && !resendLaunch, shellStatus, environmentConnected: environment?.connectionState === "connected", }); @@ -1397,7 +1416,7 @@ export function createThreadOutboxDelivery(): { } return deliveryAction === "remove" ? creation !== undefined - ? // After restart, always recover delivered creations; lost in-memory state cannot distinguish edits from duplicate payloads. + ? // Edited since launch, or launched before a restart that lost the revision: hand it back. recoverEditedCreationAfterDelivery(nextQueuedMessage) : removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") : creation !== undefined diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index f7fe9ebbcf57..f25037f40f0b 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1901,7 +1901,7 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(second).text).toBe("second idea"); }); - it("retargets a new-task draft to another project without losing its text", () => { + it("retargets a new-task draft to another project, keeping only a pull request workspace", () => { const from = { environmentId: EnvironmentId.make("environment-1"), projectId: ProjectId.make("project-1"), @@ -1922,7 +1922,7 @@ describe("mobile composer drafts", () => { }); const createdAt = getComposerDraftSnapshot(key).project?.createdAt; - retargetNewTaskDraft(key, to); + retargetNewTaskDraft(key, to, { keepPullRequestWorkspace: true }); const moved = getComposerDraftSnapshot(key); expect(moved.text).toBe("moving house"); @@ -1932,6 +1932,22 @@ describe("mobile composer drafts", () => { expect(moved.project).toEqual({ ...to, createdAt }); expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]); expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); + + const pullRequestSelection = { + mode: "worktree" as const, + branch: "feature/pr-head", + worktreePath: "/old-machine/worktrees/pr-42", + startFromOrigin: false, + pullRequestReference: "https://github.com/acme/repo/pull/42", + }; + updateComposerDraftSettings(key, { workspaceSelection: pullRequestSelection }); + retargetNewTaskDraft(key, from, { keepPullRequestWorkspace: true }); + expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual({ + ...pullRequestSelection, + worktreePath: null, + }); + retargetNewTaskDraft(key, to); + expect(getComposerDraftSnapshot(key).workspaceSelection).toBeUndefined(); }); it("retargets a saved new-task draft only after drafts load", async () => { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 2e4e53ef6dd4..7fb44be817f3 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1965,6 +1965,7 @@ export function createNewTaskDraft(project: { export function retargetNewTaskDraft( draftKey: string, project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, + options?: { readonly keepPullRequestWorkspace?: boolean }, ): void { updateComposerDrafts((current) => { const existing = current[draftKey]; @@ -1976,7 +1977,12 @@ export function retargetNewTaskDraft( ) { return current; } - const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing); + const { workspaceSelection, ...retained } = normalizeDraft(existing); + // The PR checkout is recreated on the target machine, so only the old local path is dropped. + const keptWorkspaceSelection = + options?.keepPullRequestWorkspace && workspaceSelection?.pullRequestReference !== undefined + ? { ...workspaceSelection, worktreePath: null } + : undefined; // Keep bytes across environment moves but drop server-specific upload stamps. const attachments = retained.attachments.map((attachment) => attachment.uploadEnvironmentId !== undefined && @@ -1988,6 +1994,7 @@ export function retargetNewTaskDraft( ...current, [draftKey]: { ...retained, + ...(keptWorkspaceSelection ? { workspaceSelection: keptWorkspaceSelection } : {}), attachments, project: { environmentId: project.environmentId, diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 8ae3480a59ed..374d07a4be6b 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -105,7 +105,7 @@ const EMPTY_RUNTIME_STATE_ATOM = Atom.make(null) Atom.withLabel("mobile:environment-runtime-state:empty"), ); -const savedConnectionsByIdAtom = Atom.make((get) => { +export const savedConnectionsByIdAtom = Atom.make((get) => { const catalog = get(environmentCatalog.catalogValueAtom); return Object.fromEntries( [...catalog.entries.keys()].flatMap((environmentId) => { diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 34567cc2b2c6..a01f1e9d82da 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -2,7 +2,7 @@ import { combineTerminalSessionState, EMPTY_TERMINAL_BUFFER_STATE, EMPTY_TERMINAL_SESSION_STATE, - terminalOutputText, + terminalOutputReplay, type KnownTerminalSession, type TerminalSessionState, } from "@t3tools/client-runtime/state/terminal"; @@ -57,8 +57,8 @@ export function useAttachedTerminalSession(input: { ); const output = attach.data?.output ?? EMPTY_TERMINAL_BUFFER_STATE.output; const materializeBuffer = input.materializeBuffer ?? true; - // Older installed binaries and the text fallback still need initialBuffer. - const buffer = materializeBuffer ? terminalOutputText(output) : ""; + // Older installed binaries are fed this as initialBuffer; clearing it would recreate their emulator. + const buffer = materializeBuffer ? terminalOutputReplay(output) : ""; return useMemo(() => { if (input.environmentId === null || input.terminal === null) { diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 681af43bdf34..582e828842ed 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -28,6 +28,7 @@ import { vi } from "vite-plus/test"; import * as AssetSigner from "./AssetSigner.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; +import { makeFakeWslHost } from "../testUtils/fakeWsl.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; @@ -570,6 +571,35 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "resolves a Windows drive media path under WSL to where wslpath mounts it", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { mountRoot, underWsl } = yield* makeFakeWslHost; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-wsl-root-" }); + const filePath = path.join(mountRoot, "c", "Users", "me", "Pictures", "shot.png"); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFile(filePath, screenshotPng); + const canonicalFile = yield* fs.realPath(filePath); + const drivePath = "C:\\Users\\me\\Pictures\\shot.png"; + + for (const resource of [ + { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: drivePath }, + { _tag: "draft-workspace-file", cwd: workspaceRoot, path: drivePath }, + ] as const) { + const result = yield* underWsl(issueAssetUrl({ resource, workspaceRoot })); + expect(result.imageDimensions).toEqual({ width: 390, height: 844 }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ kind: "file", path: canonicalFile, mimeType: "image/png" }); + } + }).pipe(Effect.provide(layerTest)), + ); + // Resolving `file://attacker.test/share/cat.jpg` from an agent's markdown sent the // Windows user's NTLM credentials to attacker.test. it.effect("never resolves a network media path outside the thread's project", () => diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index ee211b2bdc36..c841f8eecff2 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -57,6 +57,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import { isWithinRoot } from "../workspace/KnownWorkspaces.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { toWslDrivePath } from "../workspace/wslDrivePath.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { extractIcnsPng } from "./icns.ts"; @@ -548,7 +549,10 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "media-file": { - let requestedPath = expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory); + let requestedPath = expandHomePath( + yield* toWslDrivePath(input.resource.path), + yield* HostProcess.HomeDirectory, + ); if (!path.isAbsolute(requestedPath)) { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); @@ -605,7 +609,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const draftWorkspaceRoot = input.workspaceRoot ?? input.resource.cwd; const hostPath = input.resource.path.startsWith("~/") ? expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory) - : input.resource.path; + : yield* toWslDrivePath(input.resource.path); if (path.isAbsolute(hostPath)) { const finalized = yield* finalizeAbsoluteMediaFileAsset({ requestedPath: hostPath, diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 14f12059ce5c..365203e03db2 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,6 +1,10 @@ import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { authScopeResponse, AuthStandardClientScopes } from "@t3tools/contracts"; +import { + authScopeResponse, + AuthStandardClientScopes, + connectDeviceVerificationCode, +} from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -300,6 +304,117 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer))( }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))), ); + it.effect( + "holds an untrusted T3 Connect device until it is approved, and revocation cuts it off", + () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const deviceKey = "Kq3vZ8sYbTn0eW1pLr5uXc7dHf9gJm2aQs4tVw6yBzE"; + const admin = yield* serverAuth.issueSession({ label: "Owner" }); + const exchange = (credential: string) => + serverAuth.exchangeBootstrapCredentialForAccessToken( + credential, + undefined, + requestMetadata, + { proofKeyThumbprint: deviceKey }, + ); + + // Relay mints can arrive together; they must share one request and one code. + const mint = serverAuth.issueConnectCredential({ + proofKeyThumbprint: deviceKey, + requireApproval: true, + }); + const [requested, concurrent] = yield* Effect.all([mint, mint], { + concurrency: "unbounded", + }); + expect(concurrent.credential).toBe(requested.credential); + expect((yield* exchange(requested.credential).pipe(Effect.flip))._tag).toBe( + "ServerAuthConnectApprovalPendingError", + ); + const [request] = (yield* serverAuth.listPairingLinks()).filter( + (link) => link.connectDeviceRequest !== undefined, + ); + expect(request?.connectDeviceRequest).toEqual({ + verificationCode: connectDeviceVerificationCode(deviceKey), + client: requestMetadata, + }); + // A retry through the relay keeps the one request the approver is looking at. + const retried = yield* serverAuth.issueConnectCredential({ + proofKeyThumbprint: deviceKey, + requireApproval: true, + }); + expect(retried.credential).toBe(requested.credential); + + expect(yield* serverAuth.approveConnectDevice(request!.id)).toBe(true); + expect( + (yield* serverAuth.listPairingLinks()).some((link) => link.connectDeviceRequest), + ).toBe(false); + yield* exchange(requested.credential); + expect( + (yield* serverAuth.listConnectDevices()).map((device) => device.proofKeyThumbprint), + ).toEqual([deviceKey]); + + const refreshed = yield* serverAuth.issueConnectCredential({ + proofKeyThumbprint: deviceKey, + requireApproval: true, + }); + yield* exchange(refreshed.credential); + + const deviceSessions = () => + serverAuth + .listClientSessions(admin.sessionId) + .pipe( + Effect.map((sessions) => + sessions.filter( + (session) => session.subject === EnvironmentAuth.CLOUD_CONNECT_SUBJECT, + ), + ), + ); + // A credential minted before revocation must not let the device back in afterwards. + const unspent = yield* serverAuth.issueConnectCredential({ + proofKeyThumbprint: deviceKey, + requireApproval: true, + }); + const [oldestDeviceSession, newestDeviceSession] = yield* deviceSessions(); + expect(newestDeviceSession).toBeDefined(); + // Authorized clients lists T3 Connect sessions under this name. + expect(newestDeviceSession?.client.label).toBe("T3 Connect"); + // Revoking any one of the device's sessions ends all of them and its trust. + expect( + yield* serverAuth.revokeClientSession(admin.sessionId, oldestDeviceSession!.sessionId), + ).toBe(true); + expect(yield* deviceSessions()).toEqual([]); + expect(yield* serverAuth.listConnectDevices()).toEqual([]); + expect((yield* exchange(unspent.credential).pipe(Effect.flip))._tag).toBe( + "ServerAuthInvalidCredentialError", + ); + expect(yield* serverAuth.listConnectDevices()).toEqual([]); + const afterRevoke = yield* serverAuth.issueConnectCredential({ + proofKeyThumbprint: deviceKey, + requireApproval: true, + }); + expect((yield* exchange(afterRevoke.credential).pipe(Effect.flip))._tag).toBe( + "ServerAuthConnectApprovalPendingError", + ); + // Deny sticks: retries get the dead request back instead of a fresh prompt. + const [denied] = (yield* serverAuth.listPairingLinks()).filter( + (link) => link.connectDeviceRequest !== undefined, + ); + expect(yield* serverAuth.revokePairingLink(denied!.id)).toBe(true); + const afterDeny = yield* serverAuth.issueConnectCredential({ + proofKeyThumbprint: deviceKey, + requireApproval: true, + }); + expect(afterDeny.credential).toBe(afterRevoke.credential); + expect((yield* exchange(afterDeny.credential).pipe(Effect.flip))._tag).toBe( + "ServerAuthInvalidCredentialError", + ); + expect( + (yield* serverAuth.listPairingLinks()).some((link) => link.connectDeviceRequest), + ).toBe(false); + }).pipe(Effect.provide(layerEnvironmentAuth())), + ); + it.effect("preserves pairing grants after rejecting scopes they do not grant", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index a75a80a25467..34fdba346309 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -8,6 +8,7 @@ import { type AuthBrowserSessionResult, type AuthClientMetadata, type AuthClientSession, + type AuthConnectDevice, type AuthCreatePairingCredentialInput, type AuthEnvironmentScope, type AuthPairingLink, @@ -55,6 +56,8 @@ import { requestUrl } from "../requestUrl.ts"; const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; +/** Sessions and pairing links T3 Connect minted for a device signed in to the linked account. */ +export const CLOUD_CONNECT_SUBJECT = "cloud-connect"; export interface IssuedPairingLink { readonly id: string; @@ -337,6 +340,17 @@ export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedError()( + "ServerAuthConnectDevicesError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to update T3 Connect devices."; + } +} + export const ServerAuthInternalError = Schema.Union([ ServerAuthBootstrapCredentialValidationError, ServerAuthSessionCredentialValidationError, @@ -360,6 +374,7 @@ export const ServerAuthInternalError = Schema.Union([ ServerAuthCloudRelayIssuerMissingError, ServerAuthCloudHealthJwtSigningError, ServerAuthCloudMintJwtSigningError, + ServerAuthConnectDevicesError, ]); export type ServerAuthInternalError = typeof ServerAuthInternalError.Type; export const isServerAuthInternalError = Schema.is(ServerAuthInternalError); @@ -431,6 +446,16 @@ export const serverAuthInvalidRequestReason = ( ): "invalid_scope" | "scope_not_granted" => error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; +/** The credential is fine, but the host has not approved this T3 Connect device yet. */ +export class ServerAuthConnectApprovalPendingError extends Schema.TaggedError()( + "ServerAuthConnectApprovalPendingError", + {}, +) { + override get message(): string { + return "This T3 Connect device is waiting for approval."; + } +} + export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError()( "ServerAuthMcpApprovalCodeError", { reason: Schema.Literals(["unknown_or_used", "not_a_pairing_code", "insufficient_scope"]) }, @@ -482,7 +507,10 @@ export class EnvironmentAuth extends Context.Service< }, ) => Effect.Effect< AuthAccessTokenResult, - ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + | ServerAuthInvalidCredentialError + | ServerAuthInvalidRequestError + | ServerAuthConnectApprovalPendingError + | ServerAuthInternalError >; readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; @@ -492,10 +520,43 @@ export class EnvironmentAuth extends Context.Service< readonly proofKeyThumbprint?: string; readonly purpose?: "startup"; readonly mcpApproval?: AuthMcpClientAccess; + readonly requiresConnectApproval?: boolean; }) => Effect.Effect< IssuedPairingLink, ServerAuthInternalError | ManagedLimits.ManagedPolicyRefusedError >; + /** + * The one-time credential T3 Connect hands a device. With approval required, an untrusted + * device key gets one that waits for approval, and keeps it across retries. + */ + readonly issueConnectCredential: (input: { + readonly proofKeyThumbprint: string; + readonly requireApproval: boolean; + /** Standard client scopes when omitted. */ + readonly scopes?: ReadonlyArray; + }) => Effect.Effect< + { readonly credential: string; readonly expiresAt: DateTime.Utc }, + ServerAuthInternalError | ManagedLimits.ManagedPolicyRefusedError + >; + readonly approveConnectDevice: ( + pairingLinkId: string, + ) => Effect.Effect; + /** Approves the waiting device that shows this code, for hosts without a client at hand. */ + readonly approveConnectDeviceByCode: ( + verificationCode: string, + ) => Effect.Effect; + /** Revokes the one trusted device that shows this code; two that share it are ambiguous. */ + readonly revokeConnectDeviceByCode: ( + verificationCode: string, + ) => Effect.Effect<"revoked" | "not-found" | "ambiguous", ServerAuthInternalError>; + readonly listConnectDevices: () => Effect.Effect< + ReadonlyArray, + ServerAuthInternalError + >; + /** Stops trusting a device key and ends the sessions it holds. */ + readonly revokeConnectDevice: ( + proofKeyThumbprint: string, + ) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, ) => Effect.Effect< @@ -963,21 +1024,33 @@ export const make = Effect.gen(function* () { readonly requiredScopes?: ReadonlyArray; /** Only an agent sign-in opts in to spending a code issued for it. */ readonly mcpApproval?: AuthMcpClientAccess; + /** Shown to approvers when the credential waits on T3 Connect device approval. */ + readonly requestMetadata?: AuthClientMetadata; }, ): Effect.Effect< ResolvedBootstrapGrant, - ServerAuthInvalidCredentialError | ServerAuthInternalError | ServerAuthScopeNotGrantedError + | ServerAuthInvalidCredentialError + | ServerAuthInternalError + | ServerAuthScopeNotGrantedError + | ServerAuthConnectApprovalPendingError > => { if (!devAuth?.matches(credential)) { - return bootstrapCredentials - .consume(credential, input) - .pipe( - Effect.mapError((cause) => - cause._tag === "BootstrapCredentialScopeNotGrantedError" - ? new ServerAuthScopeNotGrantedError({}) + return bootstrapCredentials.consume(credential, input).pipe( + Effect.catchTags({ + BootstrapCredentialConnectApprovalPendingError: (pending) => + recordConnectRequest(pending.pairingLinkId, input?.requestMetadata).pipe( + Effect.andThen(Effect.fail(new ServerAuthConnectApprovalPendingError({}))), + ), + }), + Effect.mapError((cause) => + cause._tag === "BootstrapCredentialScopeNotGrantedError" + ? new ServerAuthScopeNotGrantedError({}) + : cause._tag === "ServerAuthConnectApprovalPendingError" || + cause._tag === "ServerAuthConnectDevicesError" + ? cause : toBootstrapExchangeError(cause), - ), - ); + ), + ); } return sessions.verify(credential).pipe( mapSessionVerificationErrors, @@ -998,71 +1071,84 @@ export const make = Effect.gen(function* () { ); }; + // T3 Connect credential issuance, DPoP exchanges and device revocation take turns. Two mints + // at once would open two requests with one code, and a revoke landing between an exchange + // spending its credential and trusting the key would be undone by that exchange. + const connectTrustLock = yield* Semaphore.make(1); + const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => { - return credentialExchangeLock - .withPermits(1)( - resolveBootstrapGrant(credential, { - ...input, - ...(requestedScopes !== undefined ? { requestedScopes } : {}), - }).pipe( - Effect.flatMap((grant) => - Effect.gen(function* () { - const grantedScopes = - requestedScopes === undefined - ? grant.scopes - : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); - if (grantedScopes.length === 0) { - return yield* new ServerAuthScopeNotGrantedError({}); - } - return yield* sessions - .issue({ - method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", - subject: grant.subject, - scopes: grantedScopes, - ...(input?.proofKeyThumbprint - ? { - proofKeyThumbprint: input.proofKeyThumbprint, - ttl: Duration.hours(1), - } - : {}), - replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", - client: { - ...requestMetadata, - ...(grant.label ? { label: grant.label } : {}), - }, - }) - .pipe( - Effect.mapError( - (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), - ), - ); - }), - ), - ), - ) - .pipe( - Effect.flatMap((session) => - DateTime.now.pipe( - Effect.map( - (now) => - ({ - access_token: session.token, - issued_token_type: AuthAccessTokenType, - token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", - expires_in: Math.max( - 0, - Math.floor( - (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000, - ), + return resolveBootstrapGrant(credential, { + ...input, + requestMetadata, + ...(requestedScopes !== undefined ? { requestedScopes } : {}), + }).pipe( + // Every T3 Connect key that gets in is remembered, so turning approval on later + // keeps the devices already in use and lists them for review. + Effect.tap((grant) => + grant.subject === CLOUD_CONNECT_SUBJECT && input?.proofKeyThumbprint + ? bootstrapCredentials + .trustConnectDevice(input.proofKeyThumbprint, requestMetadata) + .pipe(Effect.mapError((cause) => new ServerAuthConnectDevicesError({ cause }))) + : Effect.void, + ), + Effect.flatMap((grant) => + Effect.gen(function* () { + const grantedScopes = + requestedScopes === undefined + ? grant.scopes + : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); + if (grantedScopes.length === 0) { + return yield* new ServerAuthScopeNotGrantedError({}); + } + return yield* sessions + .issue({ + method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", + subject: grant.subject, + scopes: grantedScopes, + ...(input?.proofKeyThumbprint + ? { + proofKeyThumbprint: input.proofKeyThumbprint, + ttl: Duration.hours(1), + } + : {}), + replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), + ), + ); + }), + ), + Effect.flatMap((session) => + DateTime.now.pipe( + Effect.map( + (now) => + ({ + access_token: session.token, + issued_token_type: AuthAccessTokenType, + token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", + expires_in: Math.max( + 0, + Math.floor( + (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000, ), - scope: encodeOAuthScope(session.scopes), - }) satisfies AuthAccessTokenResult, - ), + ), + scope: encodeOAuthScope(session.scopes), + }) satisfies AuthAccessTokenResult, ), ), - Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), - ); + ), + (exchange) => + input?.proofKeyThumbprint ? connectTrustLock.withPermits(1)(exchange) : exchange, + credentialExchangeLock.withPermits(1), + Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), + ); }; const toPairingCredential = (issued: IssuedPairingLink): AuthPairingCredentialResult => ({ @@ -1083,6 +1169,7 @@ export const make = Effect.gen(function* () { ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), ...(input?.purpose ? { purpose: input.purpose } : {}), ...(input?.mcpApproval ? { mcpApproval: input.mcpApproval } : {}), + ...(input?.requiresConnectApproval ? { requiresConnectApproval: true } : {}), }); return { id: issued.id, @@ -1137,6 +1224,105 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.revokePairingLink"), ); + const recordConnectRequest = (pairingLinkId: string, client: AuthClientMetadata | undefined) => + client === undefined + ? Effect.void + : bootstrapCredentials + .recordConnectRequestClient(pairingLinkId, client) + .pipe(Effect.mapError((cause) => new ServerAuthConnectDevicesError({ cause }))); + + const issueConnectCredential: EnvironmentAuth["Service"]["issueConnectCredential"] = Effect.fn( + "EnvironmentAuth.issueConnectCredential", + )(function* (input) { + return yield* issueConnectCredentialLocked(input).pipe(connectTrustLock.withPermits(1)); + }); + // Untraced: the issueConnectCredential span above already covers it. + const issueConnectCredentialLocked = Effect.fnUntraced(function* ({ + proofKeyThumbprint, + requireApproval, + scopes = AuthStandardClientScopes, + }: Parameters[0]) { + const mustWait = + requireApproval && + !(yield* bootstrapCredentials + .isConnectDeviceTrusted(proofKeyThumbprint) + .pipe(Effect.mapError((cause) => new ServerAuthConnectDevicesError({ cause })))); + if (mustWait) { + const pending = yield* bootstrapCredentials + .findPendingConnect(proofKeyThumbprint) + .pipe(Effect.mapError((cause) => new ServerAuthConnectDevicesError({ cause }))); + if (Option.isSome(pending)) { + return { credential: pending.value.credential, expiresAt: pending.value.expiresAt }; + } + } + const issued = yield* createPairingLink({ + scopes, + subject: CLOUD_CONNECT_SUBJECT, + // Long enough for someone to notice the request and approve it. + ttl: mustWait ? Duration.minutes(10) : Duration.minutes(2), + label: "T3 Connect", + proofKeyThumbprint, + ...(mustWait ? { requiresConnectApproval: true } : {}), + }); + return { credential: issued.credential, expiresAt: issued.expiresAt }; + }); + + const approveConnectDevice: EnvironmentAuth["Service"]["approveConnectDevice"] = ( + pairingLinkId, + ) => + bootstrapCredentials.approveConnectRequest(pairingLinkId).pipe( + Effect.mapError((cause) => new ServerAuthConnectDevicesError({ cause })), + Effect.withSpan("EnvironmentAuth.approveConnectDevice"), + ); + + const approveConnectDeviceByCode: EnvironmentAuth["Service"]["approveConnectDeviceByCode"] = + Effect.fn("EnvironmentAuth.approveConnectDeviceByCode")(function* (verificationCode) { + const wanted = normalizeVerificationCode(verificationCode); + const links = yield* bootstrapCredentials + .listActive() + .pipe(Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause }))); + const matches = links.filter( + (link) => + link.connectDeviceRequest !== undefined && + normalizeVerificationCode(link.connectDeviceRequest.verificationCode) === wanted, + ); + // Two waiting devices that share a code are ambiguous; approve neither. + if (matches.length !== 1) return false; + return yield* approveConnectDevice(matches[0]!.id); + }); + + const listConnectDevices: EnvironmentAuth["Service"]["listConnectDevices"] = () => + bootstrapCredentials.listConnectDevices().pipe( + Effect.mapError((cause) => new ServerAuthConnectDevicesError({ cause })), + Effect.withSpan("EnvironmentAuth.listConnectDevices"), + ); + + const revokeConnectDevice: EnvironmentAuth["Service"]["revokeConnectDevice"] = Effect.fn( + "EnvironmentAuth.revokeConnectDevice", + )(function* (proofKeyThumbprint) { + return yield* Effect.gen(function* () { + const removed = yield* bootstrapCredentials + .revokeConnectDevice(proofKeyThumbprint) + .pipe(Effect.mapError((cause) => new ServerAuthConnectDevicesError({ cause }))); + const revokedSessions = yield* sessions + .revokeByProofKey(proofKeyThumbprint) + .pipe(Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause }))); + return removed || revokedSessions > 0; + }).pipe(connectTrustLock.withPermits(1)); + }); + + const revokeConnectDeviceByCode: EnvironmentAuth["Service"]["revokeConnectDeviceByCode"] = + Effect.fn("EnvironmentAuth.revokeConnectDeviceByCode")(function* (verificationCode) { + const wanted = normalizeVerificationCode(verificationCode); + const matches = (yield* listConnectDevices()).filter( + (device) => normalizeVerificationCode(device.verificationCode) === wanted, + ); + if (matches.length === 0) return "not-found" as const; + if (matches.length > 1) return "ambiguous" as const; + yield* revokeConnectDevice(matches[0]!.proofKeyThumbprint); + return "revoked" as const; + }); + const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => sessions .issue({ @@ -1249,15 +1435,33 @@ export const make = Effect.gen(function* () { if (currentSessionId === targetSessionId) { return yield* new ServerAuthForbiddenOperationError({}); } + const proofKeyThumbprint = yield* sessions + .proofKeyThumbprintOf(targetSessionId) + .pipe(Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause }))); + // A T3 Connect device would otherwise mint itself a new session right away. + if (Option.isSome(proofKeyThumbprint)) { + const currentProofKey = yield* sessions + .proofKeyThumbprintOf(currentSessionId) + .pipe(Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause }))); + if (Option.getOrUndefined(currentProofKey) !== proofKeyThumbprint.value) { + return yield* revokeConnectDevice(proofKeyThumbprint.value); + } + } return yield* revokeSession(targetSessionId); }); - const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = ( - currentSessionId, - ) => - revokeOtherSessionsExcept(currentSessionId).pipe( - Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), - ); + const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = + Effect.fn("EnvironmentAuth.revokeOtherClientSessions")(function* (currentSessionId) { + const currentProofKey = yield* sessions + .proofKeyThumbprintOf(currentSessionId) + .pipe(Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause }))); + return yield* Effect.gen(function* () { + yield* bootstrapCredentials + .revokeConnectDevicesExcept(Option.getOrNull(currentProofKey)) + .pipe(Effect.mapError((cause) => new ServerAuthConnectDevicesError({ cause }))); + return yield* revokeOtherSessionsExcept(currentSessionId); + }).pipe(connectTrustLock.withPermits(1)); + }); const isSessionActive: EnvironmentAuth["Service"]["isSessionActive"] = (sessionId) => listSessions().pipe( @@ -1452,6 +1656,8 @@ export const make = Effect.gen(function* () { Effect.catchTags({ ServerAuthInvalidCredentialError: () => Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), + ServerAuthConnectApprovalPendingError: () => + Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), ServerAuthScopeNotGrantedError: () => Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), }), @@ -1476,6 +1682,12 @@ export const make = Effect.gen(function* () { createBrowserSession, exchangeBootstrapCredentialForAccessToken, createPairingLink, + issueConnectCredential, + approveConnectDevice, + approveConnectDeviceByCode, + listConnectDevices, + revokeConnectDevice, + revokeConnectDeviceByCode, issuePairingCredential, issueStartupPairingCredential, listPairingLinks, @@ -1503,6 +1715,8 @@ export const make = Effect.gen(function* () { const isMcpClientAccess = Schema.is(AuthMcpClientAccess); +const normalizeVerificationCode = (code: string) => code.toUpperCase().replace(/[^0-9A-Z]/g, ""); + /** The level an approval code's subject names, or undefined for any other code. */ const mcpApprovalLevel = (subject: string): AuthMcpClientAccess | undefined => { const level = subject.startsWith(MCP_APPROVAL_SUBJECT_PREFIX) @@ -1512,7 +1726,7 @@ const mcpApprovalLevel = (subject: string): AuthMcpClientAccess | undefined => { }; /** Subjects of sessions and links a pairing link created, keyed by how the client paired. */ -const LINK_PAIRED_SUBJECTS = { link: "one-time-token", connect: "cloud-connect" } as const; +const LINK_PAIRED_SUBJECTS = { link: "one-time-token", connect: CLOUD_CONNECT_SUBJECT } as const; /** * Revokes sessions and unused links from pairing the policy no longer allows, so a policy rolled diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index bf9eccee8c51..f5f558d57526 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -1,5 +1,7 @@ import { AuthStandardClientScopes, + type AuthClientMetadata, + type AuthConnectDevice, type AuthEnvironmentScope, type AuthMcpClientAccess, type AuthPairingLink, @@ -8,6 +10,7 @@ import { PAIRING_CODE_LENGTH, parseMcpApprovalCode, type ServerAuthBootstrapMethod, + connectDeviceVerificationCode, } from "@t3tools/contracts"; import { DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, @@ -27,6 +30,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; +import * as AuthConnectDevices from "../persistence/AuthConnectDevices.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { @@ -83,11 +87,24 @@ export class BootstrapCredentialScopeNotGrantedError extends Schema.TaggedError< } } +/** The credential is valid but its T3 Connect device key still waits for approval. */ +export class BootstrapCredentialConnectApprovalPendingError extends Schema.TaggedError()( + "BootstrapCredentialConnectApprovalPendingError", + { + pairingLinkId: Schema.String, + }, +) { + override get message(): string { + return "This T3 Connect device is waiting for approval."; + } +} + export const BootstrapCredentialInvalidError = Schema.Union([ UnknownBootstrapCredentialError, ExpiredBootstrapCredentialError, BootstrapCredentialProofKeyMismatchError, UnavailableBootstrapCredentialError, + BootstrapCredentialConnectApprovalPendingError, ]); export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; @@ -162,6 +179,18 @@ export class BootstrapCredentialConsumeAvailableError extends Schema.TaggedError } } +export class ConnectDevicesError extends Schema.TaggedError()( + "ConnectDevicesError", + { + operation: Schema.Literals(["list", "trust", "revoke", "record-request"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} T3 Connect devices.`; + } +} + export class BootstrapCredentialLookupError extends Schema.TaggedError()( "BootstrapCredentialLookupError", { @@ -181,6 +210,7 @@ export const BootstrapCredentialInternalError = Schema.Union([ BootstrapCredentialConsumeError, BootstrapCredentialConsumeAvailableError, BootstrapCredentialLookupError, + ConnectDevicesError, ]); export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type; export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError); @@ -208,6 +238,9 @@ export type BootstrapCredentialChange = | { readonly type: "pairingLinkRemoved"; readonly id: string; + } + | { + readonly type: "connectDevicesChanged"; }; export class PairingGrantStore extends Context.Service< @@ -222,7 +255,39 @@ export class PairingGrantStore extends Context.Service< readonly purpose?: "startup"; /** Issues a hidden `-` code that only an agent sign-in at this level can spend. */ readonly mcpApproval?: AuthMcpClientAccess; + /** Spendable only after someone trusts `proofKeyThumbprint` as a T3 Connect device. */ + readonly requiresConnectApproval?: boolean; }) => Effect.Effect; + /** A device's open or denied request, reused so retries keep one request and one code. */ + readonly findPendingConnect: ( + proofKeyThumbprint: string, + ) => Effect.Effect, BootstrapCredentialInternalError>; + /** Shows approvers what a waiting device says it is. Only its first description sticks. */ + readonly recordConnectRequestClient: ( + pairingLinkId: string, + client: AuthClientMetadata, + ) => Effect.Effect; + /** Trusts the device key behind a waiting request. False when it is gone or not a request. */ + readonly approveConnectRequest: ( + pairingLinkId: string, + ) => Effect.Effect; + readonly isConnectDeviceTrusted: ( + proofKeyThumbprint: string, + ) => Effect.Effect; + readonly trustConnectDevice: ( + proofKeyThumbprint: string, + client: AuthClientMetadata, + ) => Effect.Effect; + readonly listConnectDevices: () => Effect.Effect< + ReadonlyArray, + BootstrapCredentialInternalError + >; + readonly revokeConnectDevice: ( + proofKeyThumbprint: string, + ) => Effect.Effect; + readonly revokeConnectDevicesExcept: ( + keepProofKeyThumbprint: string | null, + ) => Effect.Effect; readonly listActive: () => Effect.Effect< ReadonlyArray, BootstrapCredentialInternalError @@ -259,6 +324,23 @@ type ConsumeResult = }; const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); + +const toPairingLink = (row: AuthPairingLinks.AuthPairingLinkRecord): AuthPairingLink => ({ + id: row.id, + scopes: row.scopes, + subject: row.subject, + ...(row.label ? { label: row.label } : {}), + createdAt: row.createdAt, + expiresAt: row.expiresAt, + ...(row.requiresConnectApproval === 1 && row.proofKeyThumbprint + ? { + connectDeviceRequest: { + verificationCode: connectDeviceVerificationCode(row.proofKeyThumbprint), + ...(row.requestedClient ? { client: row.requestedClient } : {}), + }, + } + : {}), +}); const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); const DEV_STARTUP_TTL_HOURS = Duration.hours(24); const PAIRING_TOKEN_REJECTION_LIMIT = @@ -273,6 +355,7 @@ const make = Effect.gen(function* () { const random = yield* SecureRandom.SecureRandom; const config = yield* ServerConfig.ServerConfig; const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; + const connectDevices = yield* AuthConnectDevices.AuthConnectDeviceRepository; const seededGrantsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); const generatePairingToken = Effect.gen(function* () { @@ -348,25 +431,10 @@ const make = Effect.gen(function* () { function* () { const now = yield* DateTime.now; const rows = yield* pairingLinks.listActive({ now }); - - return rows.map((row) => - row.label - ? ({ - id: row.id, - scopes: row.scopes, - subject: row.subject, - label: row.label, - createdAt: row.createdAt, - expiresAt: row.expiresAt, - } satisfies AuthPairingLink) - : ({ - id: row.id, - scopes: row.scopes, - subject: row.subject, - createdAt: row.createdAt, - expiresAt: row.expiresAt, - } satisfies AuthPairingLink), - ); + // An approved request only waits for its device to come back and spend it. + return rows + .filter((row) => !(row.requiresConnectApproval === 1 && row.connectApproved === 1)) + .map(toPairingLink); }, Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })), ); @@ -426,6 +494,7 @@ const make = Effect.gen(function* () { subject, label: input?.label ?? null, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, + ...(input?.requiresConnectApproval ? { requiresConnectApproval: true } : {}), createdAt: now, expiresAt: expiresAt, }) @@ -449,6 +518,13 @@ const make = Effect.gen(function* () { ...(input?.label ? { label: input.label } : {}), createdAt: now, expiresAt, + ...(input?.requiresConnectApproval && input.proofKeyThumbprint + ? { + connectDeviceRequest: { + verificationCode: connectDeviceVerificationCode(input.proofKeyThumbprint), + }, + } + : {}), }); } return issued; @@ -635,12 +711,153 @@ const make = Effect.gen(function* () { return yield* new BootstrapCredentialScopeNotGrantedError({}); } + if (matching.value.requiresConnectApproval === 1 && matching.value.connectApproved === 0) { + return yield* new BootstrapCredentialConnectApprovalPendingError({ + pairingLinkId: matching.value.id, + }); + } + return yield* new UnavailableBootstrapCredentialError({}); }, ); + const emitConnectDevicesChanged = PubSub.publish(changesPubSub, { + type: "connectDevicesChanged", + }).pipe(Effect.asVoid); + + const findPendingConnect: PairingGrantStore["Service"]["findPendingConnect"] = Effect.fn( + "PairingGrantStore.findPendingConnect", + )( + function* (proofKeyThumbprint) { + const now = yield* DateTime.now; + const row = yield* pairingLinks.findPendingConnect({ proofKeyThumbprint, now }); + return Option.map(row, (link): IssuedBootstrapCredential => ({ + id: link.id, + credential: link.credential, + ...(link.label ? { label: link.label } : {}), + proofKeyThumbprint, + expiresAt: link.expiresAt, + })); + }, + Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause })), + ); + + const recordConnectRequestClient: PairingGrantStore["Service"]["recordConnectRequestClient"] = + Effect.fn("PairingGrantStore.recordConnectRequestClient")(function* (pairingLinkId, client) { + const recorded = yield* pairingLinks + .recordConnectRequestClient({ id: pairingLinkId, client }) + .pipe( + Effect.mapError( + (cause) => new ConnectDevicesError({ operation: "record-request", cause }), + ), + ); + if (!recorded) return; + const now = yield* DateTime.now; + const row = yield* pairingLinks + .getActiveById({ id: pairingLinkId, now }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); + if (Option.isSome(row)) yield* emitUpsert(toPairingLink(row.value)); + }); + + const trustConnectDevice: PairingGrantStore["Service"]["trustConnectDevice"] = Effect.fn( + "PairingGrantStore.trustConnectDevice", + )(function* (proofKeyThumbprint, client) { + const approvedAt = yield* DateTime.now; + yield* connectDevices + .trust({ proofKeyThumbprint, client, approvedAt }) + .pipe(Effect.mapError((cause) => new ConnectDevicesError({ operation: "trust", cause }))); + yield* emitConnectDevicesChanged; + }); + + const approveConnectRequest: PairingGrantStore["Service"]["approveConnectRequest"] = Effect.fn( + "PairingGrantStore.approveConnectRequest", + )(function* (pairingLinkId) { + const now = yield* DateTime.now; + const row = yield* pairingLinks + .getActiveById({ id: pairingLinkId, now }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); + if ( + Option.isNone(row) || + row.value.requiresConnectApproval !== 1 || + row.value.proofKeyThumbprint === null + ) { + return false; + } + yield* connectDevices + .trust({ + proofKeyThumbprint: row.value.proofKeyThumbprint, + client: row.value.requestedClient, + approvedAt: now, + }) + .pipe(Effect.mapError((cause) => new ConnectDevicesError({ operation: "trust", cause }))); + yield* emitRemoved(pairingLinkId); + yield* emitConnectDevicesChanged; + return true; + }); + + const isConnectDeviceTrusted: PairingGrantStore["Service"]["isConnectDeviceTrusted"] = ( + proofKeyThumbprint, + ) => + connectDevices + .isTrusted(proofKeyThumbprint) + .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); + + const listConnectDevices: PairingGrantStore["Service"]["listConnectDevices"] = () => + connectDevices.list().pipe( + Effect.map((rows) => + rows.map((row): AuthConnectDevice => ({ + proofKeyThumbprint: row.proofKeyThumbprint, + verificationCode: connectDeviceVerificationCode(row.proofKeyThumbprint), + ...(row.client ? { client: row.client } : {}), + approvedAt: row.approvedAt, + })), + ), + Effect.mapError((cause) => new ConnectDevicesError({ operation: "list", cause })), + ); + + // A credential minted before revocation is still spendable; it must die with the trust, + // or exchanging it would trust the key again without approval. + const revokeUnspentLinks = Effect.fn("PairingGrantStore.revokeUnspentLinks")(function* ( + input: Omit, + ) { + const revokedAt = yield* DateTime.now; + const ids = yield* pairingLinks + .revokeUnspentByProofKey({ ...input, revokedAt }) + .pipe(Effect.mapError((cause) => new ConnectDevicesError({ operation: "revoke", cause }))); + yield* Effect.forEach(ids, emitRemoved, { discard: true }); + }); + + const revokeConnectDevice: PairingGrantStore["Service"]["revokeConnectDevice"] = Effect.fn( + "PairingGrantStore.revokeConnectDevice", + )(function* (proofKeyThumbprint) { + yield* revokeUnspentLinks({ proofKeyThumbprint, keepProofKeyThumbprint: null }); + const removed = yield* connectDevices + .remove(proofKeyThumbprint) + .pipe(Effect.mapError((cause) => new ConnectDevicesError({ operation: "revoke", cause }))); + if (removed) yield* emitConnectDevicesChanged; + return removed; + }); + + const revokeConnectDevicesExcept: PairingGrantStore["Service"]["revokeConnectDevicesExcept"] = + Effect.fn("PairingGrantStore.revokeConnectDevicesExcept")(function* (keepProofKeyThumbprint) { + yield* revokeUnspentLinks({ proofKeyThumbprint: null, keepProofKeyThumbprint }); + const removed = yield* connectDevices + .removeAllExcept(keepProofKeyThumbprint) + .pipe(Effect.mapError((cause) => new ConnectDevicesError({ operation: "revoke", cause }))); + if (removed > 0) yield* emitConnectDevicesChanged; + return removed; + }); + return PairingGrantStore.of({ issueOneTimeToken, + findPendingConnect, + recordConnectRequestClient, + approveConnectRequest, + isConnectDeviceTrusted, + trustConnectDevice, + listConnectDevices, + revokeConnectDevice, + revokeConnectDevicesExcept, listActive, get streamChanges() { return Stream.fromPubSub(changesPubSub); @@ -652,4 +869,5 @@ const make = Effect.gen(function* () { export const layer = Layer.effect(PairingGrantStore, make).pipe( Layer.provideMerge(AuthPairingLinks.layer), + Layer.provideMerge(AuthConnectDevices.layer), ); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 4f4090c9ce23..163d563f90bb 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -4,6 +4,7 @@ import { AuthFilesystemReadScope, AuthProvidersManageScope, AuthSettingsWriteScope, + AuthAccessWriteScope, DEFAULT_SERVER_SETTINGS, ProviderDriverKind, ProviderInstanceId, @@ -267,35 +268,42 @@ describe("settings mutation authorization", () => { }), ); - it.effect("does not let a settings grant create or remove providers or secret providers", () => - Effect.gen(function* () { - let handled = false; - const client = yield* RpcTest.makeClient(group).pipe( - Effect.provide( - Layer.mergeAll( - group.toLayerHandler(WS_METHODS.serverUpdateSettings, () => - Effect.sync(() => { - handled = true; - return DEFAULT_SERVER_SETTINGS; - }), + it.effect( + "does not let a settings grant manage providers or turn off T3 Connect device approval", + () => + Effect.gen(function* () { + let handled = false; + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverUpdateSettings, () => + Effect.sync(() => { + handled = true; + return DEFAULT_SERVER_SETTINGS; + }), + ), + RpcAuthorization.layer([AuthSettingsWriteScope]), ), - RpcAuthorization.layer([AuthSettingsWriteScope]), ), - ), - ); - expect( - yield* client[WS_METHODS.serverUpdateSettings]({ - patch: {}, - providerInstanceMutation, - }).pipe(Effect.flip), - ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); - expect( - yield* client[WS_METHODS.serverUpdateSettings]({ - patch: { secretProviders: { [SecretProviderId.make("personal")]: null } }, - }).pipe(Effect.flip), - ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); - expect(handled).toBe(false); - }), + ); + expect( + yield* client[WS_METHODS.serverUpdateSettings]({ + patch: {}, + providerInstanceMutation, + }).pipe(Effect.flip), + ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); + expect( + yield* client[WS_METHODS.serverUpdateSettings]({ + patch: { secretProviders: { [SecretProviderId.make("personal")]: null } }, + }).pipe(Effect.flip), + ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); + expect( + yield* client[WS_METHODS.serverUpdateSettings]({ + patch: { requireConnectDeviceApproval: false }, + }).pipe(Effect.flip), + ).toMatchObject({ requiredPermission: AuthAccessWriteScope }); + expect(handled).toBe(false); + }), ); }); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 5a25d152c517..5717479a47ba 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -350,6 +350,18 @@ export class OtherSessionsRevocationError extends Schema.TaggedError()( + "ProofKeySessionsError", + { + operation: Schema.Literals(["lookup", "revoke"]), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return `Failed to ${this.operation} sessions by proof key.`; + } +} + export const SessionCredentialInternalError = Schema.Union([ SessionClaimsEncodingError, SessionCredentialIssueError, @@ -359,6 +371,7 @@ export const SessionCredentialInternalError = Schema.Union([ ActiveSessionsListError, SessionRevocationError, OtherSessionsRevocationError, + ProofKeySessionsError, ]); export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; @@ -419,6 +432,13 @@ export class SessionStore extends Context.Service< readonly revokeAllExcept: ( sessionId: AuthSessionId, ) => Effect.Effect; + readonly proofKeyThumbprintOf: ( + sessionId: AuthSessionId, + ) => Effect.Effect, SessionCredentialInternalError>; + /** Revokes every session bound to this DPoP key, so a device it belongs to is cut off now. */ + readonly revokeByProofKey: ( + proofKeyThumbprint: string, + ) => Effect.Effect; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; } @@ -697,6 +717,7 @@ const make = Effect.gen(function* () { }, issuedAt, expiresAt, + ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies AuthSessions.CreateAuthSessionInput; const replacedSessionIds = yield* ( input?.replaceSessionId !== undefined || input?.replaceActiveForSubjectAndMethod @@ -1005,6 +1026,27 @@ const make = Effect.gen(function* () { }, ); + const proofKeyThumbprintOf: SessionStore["Service"]["proofKeyThumbprintOf"] = (sessionId) => + authSessions + .getProofKeyThumbprint({ sessionId }) + .pipe(Effect.mapError((cause) => new ProofKeySessionsError({ operation: "lookup", cause }))); + + const revokeByProofKey: SessionStore["Service"]["revokeByProofKey"] = Effect.fn( + "SessionStore.revokeByProofKey", + )(function* (proofKeyThumbprint) { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions + .revokeByProofKey({ proofKeyThumbprint, revokedAt }) + .pipe(Effect.mapError((cause) => new ProofKeySessionsError({ operation: "revoke", cause }))); + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) next.delete(revokedSessionId); + return next; + }); + yield* Effect.forEach(revokedSessionIds, emitRemoved, { discard: true }); + return revokedSessionIds.length; + }); + const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", )(function* (sessionId) { @@ -1112,6 +1154,8 @@ const make = Effect.gen(function* () { }, revoke, revokeAllExcept, + proofKeyThumbprintOf, + revokeByProofKey, markConnected, markDisconnected, }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 7cae6bee393c..1793e87d00a4 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -53,7 +53,8 @@ export const mapDpopReplayStoreError = ( cause: error, }); -export const verifyRequestDpopProof = Effect.fn("dpop.verifyRequestDpopProof")(function* (input: { +/** Checks a proof without recording it; `consume` records its jti so it cannot be replayed. */ +export const checkRequestDpopProof = Effect.fn("dpop.checkRequestDpopProof")(function* (input: { readonly request: HttpServerRequest.HttpServerRequest; /** Overrides the `DPoP` header for requests that cannot carry one, such as a WebSocket upgrade. */ readonly proof?: string; @@ -86,42 +87,53 @@ export const verifyRequestDpopProof = Effect.fn("dpop.verifyRequestDpopProof")(f }); } const secretStore = yield* ServerSecretStore.ServerSecretStore; - const replayKey = yield* Digest.Digest.pipe( - Effect.flatMap((digest) => - digest.digest(Digest.sha256, new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)), - ), - Effect.map(Base64Url.encode), - Effect.mapError( - (cause) => - new ServerAuthDpopReplayKeyCalculationError({ - cause, - }), - ), - ); - yield* secretStore - .create( - `${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`, - new TextEncoder().encode( - [ - `thumbprint=${result.thumbprint}`, - `jti=${result.jti}`, - `iat=${result.iat}`, - `consumedAt=${DateTime.formatIso(now)}`, - ].join("\n"), - ), - ) - .pipe( - Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - Effect.gen(function* () { - const mapped = mapDpopReplayStoreError(error); - if (mapped._tag === "ServerAuthInvalidCredentialError") { - yield* Effect.annotateCurrentSpan({ - "environment.dpop.failure_code": mapped.dpopFailureReason, - }); - } - return yield* mapped; - }), - ), - ); - return result.thumbprint; + const digest = yield* Digest.Digest; + const consume: Effect.Effect = + Effect.gen(function* () { + const replayKey = yield* digest + .digest(Digest.sha256, new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)) + .pipe( + Effect.map(Base64Url.encode), + Effect.mapError( + (cause) => + new ServerAuthDpopReplayKeyCalculationError({ + cause, + }), + ), + ); + yield* secretStore + .create( + `${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`, + new TextEncoder().encode( + [ + `thumbprint=${result.thumbprint}`, + `jti=${result.jti}`, + `iat=${result.iat}`, + `consumedAt=${DateTime.formatIso(now)}`, + ].join("\n"), + ), + ) + .pipe( + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => + Effect.gen(function* () { + const mapped = mapDpopReplayStoreError(error); + if (mapped._tag === "ServerAuthInvalidCredentialError") { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": mapped.dpopFailureReason, + }); + } + return yield* mapped; + }), + ), + ); + }); + return { thumbprint: result.thumbprint, consume }; +}); + +export const verifyRequestDpopProof = Effect.fn("dpop.verifyRequestDpopProof")(function* ( + input: Parameters[0], +) { + const proof = yield* checkRequestDpopProof(input); + yield* proof.consume; + return proof.thumbprint; }); diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index 14003a36b64d..3a27fa9baba6 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -23,6 +24,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { makeDpopKey } from "./dpop.testkit.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as AuthHttp from "./http.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; @@ -191,3 +193,63 @@ it.effect.each(["DPoP", "dpop"])("challenges a refused %s request with DPoP", (s ); }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); + +// The web handler runs on its own runtime, so proofs need wall-clock time. +it.live("checks token-endpoint DPoP proofs without writing replay state", () => + Effect.gen(function* () { + const random = yield* SecureRandom.SecureRandom; + const written: Array = []; + const recordingSecretStore = ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeedNone, + set: () => Effect.void, + create: (name) => Effect.sync(() => void written.push(name)), + getOrCreateRandom: () => Effect.die("Not used by these routes."), + remove: () => Effect.void, + }); + const requestContext = Context.make(SecureRandom.SecureRandom, random).pipe( + Context.add(ServerSecretStore.ServerSecretStore, recordingSecretStore), + ); + const url = "http://127.0.0.1/oauth/token"; + const key = makeDpopKey(); + const iat = Math.floor((yield* DateTime.now).epochMilliseconds / 1_000); + const tokenRequest = (subjectToken: string, proof: string) => + new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", dpop: proof }, + body: new URLSearchParams({ + grant_type: AuthTokenExchangeGrantType, + subject_token: subjectToken, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + }), + }); + return yield* Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })), + (environment) => + Effect.promise(async () => { + const unknownToken = await environment.handler( + tokenRequest("not-a-pairing-token", key.sign({ method: "POST", url, token: "", iat })), + requestContext, + ); + expect(unknownToken.status).toBe(401); + expect(await unknownToken.json()).not.toHaveProperty("dpopFailureReason"); + + const accepted = await environment.handler( + tokenRequest(DEV_TOKEN, key.sign({ method: "POST", url, token: "", iat })), + requestContext, + ); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ token_type: "DPoP" }); + + const badProof = await environment.handler( + tokenRequest(DEV_TOKEN, "not-a-proof"), + requestContext, + ); + expect(badProof.status).toBe(401); + expect(badProof.headers.get("www-authenticate")).toBe("DPoP"); + expect(written).toEqual([]); + }), + (environment) => Effect.promise(() => environment.dispose()), + ); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), +); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 8c3698176eba..c36143ae90d0 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -5,6 +5,7 @@ import { AuthGrantScope, EnvironmentAuthInvalidError, type EnvironmentAuthInvalidReason, + EnvironmentConnectApprovalPendingError, EnvironmentHttpApi, EnvironmentHttpForbiddenError, EnvironmentInternalError, @@ -36,7 +37,7 @@ import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; -import { verifyRequestDpopProof } from "./dpop.ts"; +import { checkRequestDpopProof } from "./dpop.ts"; import { requestUrl } from "../requestUrl.ts"; const CREDENTIAL_RESPONSE_HEADERS = { @@ -253,7 +254,7 @@ export const layerAuthenticatedAuth = Layer.effect( scopes: new Set(session.scopes), }), ); - return yield* session.subject === "cloud-connect" + return yield* session.subject === EnvironmentAuth.CLOUD_CONNECT_SUBJECT ? traceAuthenticatedRelayRequest(handler, { startTime, endTime }) : handler; }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized })); @@ -364,8 +365,10 @@ export const layer = HttpApiBuilder.group( if (requestedScopes === null || requestedScopes?.length === 0) { return yield* failEnvironmentInvalidRequest("invalid_scope"); } - const proofKeyThumbprint = args.headers.dpop - ? yield* verifyRequestDpopProof({ request }).pipe( + // Not recorded: a replayer also holds the subject token and could sign its own proof. + // Recording would let anyone write replay state to disk. + const proof = args.headers.dpop + ? yield* checkRequestDpopProof({ request }).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => appendDpopChallengeHeader.pipe( Effect.andThen( @@ -395,7 +398,7 @@ export const layer = HttpApiBuilder.group( ...(args.payload.client_os ? { os: args.payload.client_os } : {}), }, }), - proofKeyThumbprint ? { proofKeyThumbprint } : undefined, + proof ? { proofKeyThumbprint: proof.thumbprint } : undefined, ); }, traceRelayRequest, @@ -405,6 +408,19 @@ export const layer = HttpApiBuilder.group( EnvironmentAuth.serverAuthDpopFailureReason(error), ), ), + Effect.catchTags({ + ServerAuthConnectApprovalPendingError: () => + currentEnvironmentTraceId.pipe( + Effect.flatMap((traceId) => + Effect.fail( + new EnvironmentConnectApprovalPendingError({ + code: "connect_approval_pending", + traceId, + }), + ), + ), + ), + }), Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), ), @@ -514,6 +530,34 @@ export const layer = HttpApiBuilder.group( ), ), ) + .handle( + "approveConnectDevice", + Effect.fn("environment.auth.approveConnectDevice")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + const approved = yield* serverAuth.approveConnectDevice(args.payload.pairingLinkId); + return { approved }; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("connect_device_approve_failed", error), + ), + ), + ) + .handle( + "revokeConnectDevice", + Effect.fn("environment.auth.revokeConnectDevice")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + const revoked = yield* serverAuth.revokeConnectDevice(args.payload.proofKeyThumbprint); + return { revoked }; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("connect_device_revoke_failed", error), + ), + ), + ) .handle( "revokeOtherClients", Effect.fn("environment.auth.revokeOtherClients")( diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 062405d53321..bf9543919de6 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -1,4 +1,5 @@ import { + CHECKPOINT_DIFF_TRUNCATED_MARKER, OrchestrationGetTurnDiffResult, type OrchestrationGetFullThreadDiffInput, type OrchestrationGetFullThreadDiffResult, @@ -28,7 +29,6 @@ import { isGitImport, parseTurnDiffFilesFromNumstat, type TurnDiffFileSummary } // Windows limits a command line to 32,767 characters. Leave room for the rest of git's arguments. const MAX_PATHSPEC_CHARS = 24_000; -const DIFF_TRUNCATED_MARKER = "\n\n[truncated]"; function batchFilePaths(files: ReadonlyArray) { const batches: string[][] = []; @@ -243,10 +243,11 @@ export const make = Effect.gen(function* () { new TextDecoder().decode( Buffer.from(diff).subarray( 0, - CHECKPOINT_DIFF_MAX_OUTPUT_BYTES - Buffer.byteLength(DIFF_TRUNCATED_MARKER), + CHECKPOINT_DIFF_MAX_OUTPUT_BYTES - + Buffer.byteLength(CHECKPOINT_DIFF_TRUNCATED_MARKER), ), { stream: true }, - ) + DIFF_TRUNCATED_MARKER; + ) + CHECKPOINT_DIFF_TRUNCATED_MARKER; break; } remainingBytes -= patchBytes; diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 62c764b537a8..15fa9205dee2 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -546,6 +546,123 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { }), ); + it.effect("leaves out fetched commits newer than the checkpoint, keeping local ones", () => + Effect.gen(function* () { + const root = yield* makeTmpDir(); + const upstream = NodePath.join(root, "upstream"); + const primary = NodePath.join(root, "primary"); + const cwd = NodePath.join(root, "turn"); + yield* (yield* FileSystem.FileSystem).makeDirectory(upstream); + yield* initRepoWithCommit(upstream); + yield* git(root, ["clone", upstream, primary]); + yield* git(primary, ["config", "user.email", "test@test.com"]); + yield* git(primary, ["config", "user.name", "Test"]); + yield* git(primary, ["worktree", "add", cwd, "-b", "turn"]); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths-fetched"); + const refs = { + cwd, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef }); + + const commitFile = (repo: string, file: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(repo, file), `${file}\n`); + yield* git(repo, ["add", "."]); + yield* git(repo, ["commit", "-m", file]); + }); + + yield* git(upstream, ["checkout", "-b", "release"]); + yield* commitFile(upstream, "first.txt"); + yield* git(cwd, ["fetch", "origin"]); + yield* commitFile(upstream, "second.txt"); + // The primary worktree's HEAD reflog is the only record that this commit was made here. + yield* git(primary, ["checkout", "--detach"]); + yield* commitFile(primary, "own.txt"); + yield* git(primary, ["push", "origin", "HEAD:refs/heads/pushed"]); + yield* git(upstream, ["merge", "pushed", "--no-edit"]); + + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["merge", "--ff-only", "origin/release"]); + yield* commitFile(cwd, "local.txt"); + // Pushing imported commits to another branch must not make them look local. + yield* git(cwd, ["push", "origin", "HEAD:refs/heads/mirror"]); + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef }); + + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual( + new Set(["local.txt", "own.txt"]), + ); + }), + ); + + it.effect.each([{ committed: "before the turn" }, { committed: "during the turn" }])( + "starts a turn that switched branches from the branch it landed on (committed $committed)", + ({ committed }) => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-branch-switch"); + const [first, second, third] = [0, 1, 2].map((turn) => + checkpointRefForThreadTurn(threadId, turn), + ) as [CheckpointRef, CheckpointRef, CheckpointRef]; + const changedSince = (fromCheckpointRef: CheckpointRef, toCheckpointRef: CheckpointRef) => + Effect.gen(function* () { + const refs = { cwd: tmp, fromCheckpointRef, toCheckpointRef }; + return { + patch: yield* checkpointStore.diffCheckpoints({ ...refs, ignoreWhitespace: false }), + authored: yield* checkpointStore.listAuthoredPaths(refs), + }; + }); + const commitOnA = git(tmp, ["commit", "-am", "work on a"]); + + yield* git(tmp, ["branch", "b"]); + yield* git(tmp, ["checkout", "-b", "a"]); + yield* writeTextFile(NodePath.join(tmp, "a.txt"), "a\n"); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# on a\n"); + yield* git(tmp, ["add", "."]); + if (committed === "before the turn") yield* commitOnA; + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: first }); + + if (committed === "during the turn") yield* commitOnA; + yield* git(tmp, ["checkout", "b"]); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# on b\n"); + yield* writeTextFile(NodePath.join(tmp, "b.txt"), "b\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: second, + previousCheckpointRef: first, + }); + + const switched = yield* changedSince(first, second); + expect(switched.patch).not.toContain("a.txt"); + expect(switched.patch).toContain("-# test\n+# on b\n"); + expect(switched.patch).toContain("b/b.txt"); + expect(switched.authored).toBeNull(); + + // A reset also moves HEAD off a commit, and what it removed is the turn's doing. + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "work on b"]); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: second, + previousCheckpointRef: first, + }); + yield* git(tmp, ["reset", "--hard", "HEAD~1"]); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: third, + previousCheckpointRef: second, + }); + + const reset = yield* changedSince(second, third); + expect(reset.patch).toContain("--- a/b.txt\n+++ /dev/null"); + expect(reset.authored).toEqual(new Set(["README.md", "b.txt"])); + }), + ); + it.effect("keeps paths from signed commits under log.showSignature", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index e5d3006568a6..8eadf2b5342a 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -10,6 +10,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export interface CaptureCheckpointInput { readonly cwd: string; readonly checkpointRef: CheckpointRef; + /** The checkpoint this one follows, so a turn that switched branches starts where it landed. */ + readonly previousCheckpointRef?: CheckpointRef; } export interface RestoreCheckpointInput { diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index b75fba88bddc..d57f0c2ef0c6 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -10,6 +10,7 @@ import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import { + formatConnectDeviceList, formatIssuedPairingCredential, formatIssuedSession, formatPairingCredentialList, @@ -166,6 +167,82 @@ const pairingCommand = Command.make("pairing").pipe( Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), ); +const connectDeviceListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription( + "List T3 Connect devices waiting for approval and those already trusted.", + ), + Command.withHandler((flags) => + runWithEnvironmentAuth( + flags, + (environmentAuth) => + Effect.gen(function* () { + const waiting = (yield* environmentAuth.listPairingLinks()).flatMap((link) => + link.connectDeviceRequest ? [link.connectDeviceRequest] : [], + ); + const trusted = yield* environmentAuth.listConnectDevices(); + yield* Console.log(formatConnectDeviceList({ waiting, trusted }, { json: flags.json })); + }), + { quietLogs: flags.json }, + ), + ), +); + +const verificationCodeArgument = Argument.String("code").pipe( + Argument.withDescription("The code the device shows, such as `K7QM-2XFD`."), +); + +const connectDeviceApproveCommand = Command.make("approve", { + ...authLocationFlags, + code: verificationCodeArgument, +}).pipe( + Command.withDescription("Approve the waiting T3 Connect device that shows this code."), + Command.withHandler((flags) => + runWithEnvironmentAuth(flags, (environmentAuth) => + Effect.gen(function* () { + const approved = yield* environmentAuth.approveConnectDeviceByCode(flags.code); + yield* Console.log( + approved + ? `Approved ${flags.code}. The device connects on its next try.\n` + : `No device waiting for approval shows ${flags.code}.\n`, + ); + }), + ), + ), +); + +const connectDeviceRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + code: verificationCodeArgument, +}).pipe( + Command.withDescription("Stop trusting a T3 Connect device and end its sessions."), + Command.withHandler((flags) => + runWithEnvironmentAuth(flags, (environmentAuth) => + Effect.gen(function* () { + const outcome = yield* environmentAuth.revokeConnectDeviceByCode(flags.code); + yield* Console.log( + outcome === "revoked" + ? `Revoked ${flags.code}. If approval for new devices is off, it can connect again on its own.\n` + : outcome === "ambiguous" + ? `More than one trusted device shows ${flags.code}; revoke it from a client.\n` + : `No trusted device shows ${flags.code}.\n`, + ); + }), + ), + ), +); + +const connectDeviceCommand = Command.make("connect-device").pipe( + Command.withDescription("Approve and revoke devices that reach this host through T3 Connect."), + Command.withSubcommands([ + connectDeviceListCommand, + connectDeviceApproveCommand, + connectDeviceRevokeCommand, + ]), +); + const sessionIssueCommand = Command.make("issue", { ...authLocationFlags, scopes: authScopesFlag(AuthStandardClientScopes), @@ -250,5 +327,5 @@ const sessionCommand = Command.make("session").pipe( export const authCommand = Command.make("auth").pipe( Command.withDescription("Manage the local auth control plane for headless deployments."), - Command.withSubcommands([pairingCommand, sessionCommand]), + Command.withSubcommands([pairingCommand, sessionCommand, connectDeviceCommand]), ); diff --git a/apps/server/src/cli/browser.test.ts b/apps/server/src/cli/browser.test.ts new file mode 100644 index 000000000000..f652cb58b210 --- /dev/null +++ b/apps/server/src/cli/browser.test.ts @@ -0,0 +1,139 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/cli"; +import { ChildProcessSpawner } from "effect/process"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; +import { browserCommand } from "./browser.ts"; + +const USERNS_RESTRICTION = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"; +const BROWSER_ROOT = "/home/me/.t3/tools/chrome-headless-shell"; +const BROWSER = `${BROWSER_ROOT}/linux64/154/chrome-headless-shell`; + +const notFound = (method: string, path: string) => + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method, + pathOrDescriptor: path, + }); + +/** An Ubuntu host with the userns restriction on, the browser installed, and a system font. */ +const runSetupAsRoot = (input: { readonly profile: string | undefined }) => { + const files = new Map([ + [USERNS_RESTRICTION, "1\n"], + [BROWSER, ""], + ]); + if (input.profile !== undefined) { + files.set(PreviewBrowserHost.APPARMOR_PROFILE_PATH, input.profile); + } + const directories = new Map([ + [BROWSER_ROOT, ["linux64"]], + [`${BROWSER_ROOT}/linux64`, ["154"]], + ]); + const commands: Array = []; + const fileSystem = FileSystem.makeNoop({ + exists: (path) => Effect.succeed(files.has(path)), + readFileString: (path) => { + const content = files.get(path); + return content === undefined + ? Effect.fail(notFound("readFileString", path)) + : Effect.succeed(content); + }, + readDirectory: (path) => { + const entries = directories.get(path); + return entries === undefined + ? Effect.fail(notFound("readDirectory", path)) + : Effect.succeed(entries); + }, + writeFileString: (path, content) => Effect.sync(() => void files.set(path, content)), + }); + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const { command: name, args } = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push([name, ...args].join(" ")); + const output = + name === "fc-match" ? "/usr/share/fonts/sans.ttf" : name === BROWSER ? "Chrome 154\n" : ""; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(output)), + stderr: Stream.empty, + all: Stream.make(new TextEncoder().encode(output)), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return Effect.gen(function* () { + yield* Command.runWith(browserCommand, { version: packageJson.version })([ + "setup", + "--base-dir", + "/home/me/.t3", + ]); + return { + output: (yield* TestConsole.logLines).join("\n"), + profile: files.get(PreviewBrowserHost.APPARMOR_PROFILE_PATH), + loads: commands.filter((command) => command.startsWith("apparmor_parser")), + }; + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.UserId, 0), + Effect.provideService(HostProcess.Environment, { SUDO_USER: "me" }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provide(Layer.mergeAll(NodeServices.layer, Layer.fresh(TestConsole.layer))), + ); +}; + +const LOAD = `apparmor_parser -r ${PreviewBrowserHost.APPARMOR_PROFILE_PATH}`; + +const CUSTOMIZED_PROFILE = PreviewBrowserHost.APPARMOR_PROFILE.replace( + " userns,\n", + " userns,\n deny /tmp/t3-admin-private/** rw,\n", +); + +it.effect.each([ + { + label: "reloads a profile that is on disk", + profile: PreviewBrowserHost.APPARMOR_PROFILE, + written: PreviewBrowserHost.APPARMOR_PROFILE, + said: "Reloaded the AppArmor profile", + }, + { + label: "writes and loads a missing profile", + profile: undefined, + written: PreviewBrowserHost.APPARMOR_PROFILE, + said: "Wrote and loaded", + }, + { + label: "reloads a customized profile without rewriting it", + profile: CUSTOMIZED_PROFILE, + written: CUSTOMIZED_PROFILE, + said: "Reloaded the AppArmor profile", + }, +])("t3 browser setup as root $label", (input) => + Effect.gen(function* () { + const result = yield* runSetupAsRoot({ profile: input.profile }); + expect(result.loads).toEqual([LOAD]); + expect(result.profile).toBe(input.written); + expect(result.output).toContain(input.said); + expect(result.output).toContain("This host is ready for T3's browser."); + }), +); diff --git a/apps/server/src/cli/browser.ts b/apps/server/src/cli/browser.ts index ea3dbd347e59..a82b84ef82d4 100644 --- a/apps/server/src/cli/browser.ts +++ b/apps/server/src/cli/browser.ts @@ -127,7 +127,14 @@ const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe ? new PreviewBrowserHost.PreviewBrowserRootError().message : undefined; - const needsProfile = yield* PreviewBrowserHost.sandboxBlocked; + const restricted = yield* PreviewBrowserHost.usernsRestricted; + // The kernel can lack a profile that is on disk, as on a host restored from an image, + // and only root can read the loaded list, so root reloads it on every run. + const needsProfile = isRoot ? restricted : yield* PreviewBrowserHost.sandboxBlocked; + const reloadHint = + restricted && !isRoot + ? `\nIf the browser still reports a blocked sandbox, the AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} is not loaded. To load it, run:\n\n ${setupCommand}\n` + : ""; const browser = yield* installedBrowser(baseDirPath); const missing = Option.isSome(browser) ? yield* PreviewBrowserHost.missingLibraries(browser.value) @@ -140,7 +147,8 @@ const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe rootWarning ?? (Option.isSome(browser) ? "This host is ready for T3's browser." - : "Chrome's sandbox is allowed here. T3's browser installs on first use; if it then reports missing libraries, run this again."), + : "Chrome's sandbox is allowed here. T3's browser installs on first use; if it then reports missing libraries, run this again.") + + reloadHint, ); } @@ -162,25 +170,35 @@ const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe } if (needsProfile) { - yield* fs - .writeFileString( - PreviewBrowserHost.APPARMOR_PROFILE_PATH, - PreviewBrowserHost.APPARMOR_PROFILE, - ) - .pipe( - Effect.mapError( - (cause) => - new BrowserSetupStepError({ - step: `write ${PreviewBrowserHost.APPARMOR_PROFILE_PATH}`, - detail: cause.message, - }), - ), - ); + // An existing profile may carry an administrator's edits, so it is reloaded as is. + const profileOnDisk = yield* fs + .exists(PreviewBrowserHost.APPARMOR_PROFILE_PATH) + .pipe(Effect.orElseSucceed(() => false)); + if (!profileOnDisk) { + yield* fs + .writeFileString( + PreviewBrowserHost.APPARMOR_PROFILE_PATH, + PreviewBrowserHost.APPARMOR_PROFILE, + ) + .pipe( + Effect.mapError( + (cause) => + new BrowserSetupStepError({ + step: `write ${PreviewBrowserHost.APPARMOR_PROFILE_PATH}`, + detail: cause.message, + }), + ), + ); + } yield* runStep("load the AppArmor profile", "apparmor_parser", [ "-r", PreviewBrowserHost.APPARMOR_PROFILE_PATH, ]); - yield* Console.log("Allowed Chrome's sandbox for T3's browser."); + yield* Console.log( + profileOnDisk + ? `Reloaded the AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} that allows Chrome's sandbox for T3's browser.` + : `Wrote and loaded the AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} that allows Chrome's sandbox for T3's browser.`, + ); } if ((missing.length > 0 || needsFonts) && !hasApt) { diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 2e91c079f161..2a51d059df67 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -64,7 +64,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) (it) => { const runConnect = ( recordedPid: number, - options?: { readonly portFreed?: boolean; readonly command?: "unlink" | "status" }, + options?: { + readonly portFreed?: boolean; + readonly command?: "unlink" | "status"; + readonly linkMode?: "managed" | "publish_only"; + }, ) => Effect.gen(function* () { const requests: Array = []; @@ -105,7 +109,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) yield* fs.makeDirectory(`${baseDir}/userdata/secrets`, { recursive: true }); yield* fs.writeFileString( `${baseDir}/userdata/secrets/${CLOUD_CLI_DESIRED_LINK_SECRET}.bin`, - "managed", + options.linkMode ?? "managed", ); } @@ -179,6 +183,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) }), ); + it.effect("status does not report a publish-only link as exposed", () => + Effect.gen(function* () { + const { printed } = yield* runConnect(process.pid, { + command: "status", + linkMode: "publish_only", + }); + + assert.include(printed, "Exposure: publish-only"); + assert.notInclude(printed, "Other devices can reach this machine"); + }), + ); + it.effect("asks a running server to stop its tunnel", () => Effect.gen(function* () { const { requests, printed, warnings } = yield* runConnect(process.pid); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index f95da6326a35..8b3a3449dff6 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -196,6 +196,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b : status.desired && status.authenticated ? "pending server startup" : "not provisioned"; + const publishOnly = status.desired && status.linkMode === "publish_only"; const nextStep = !status.authenticated ? "Run `t3 connect link` to authorize and enable T3 Connect." : status.revoked @@ -203,30 +204,37 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b : !status.desired ? "Run `t3 connect link` to enable T3 Connect." : !status.linked - ? "Start T3 to provision the environment link and launch its managed tunnel." + ? publishOnly + ? "Start T3 to provision the environment link." + : "Start T3 to provision the environment link and launch its managed tunnel." : undefined; return [ "T3 Connect", - ` Exposure: ${status.desired ? "enabled" : "disabled"}`, + ` Exposure: ${publishOnly ? "publish-only" : status.desired ? "enabled" : "disabled"}`, ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, ` Environment link: ${provisioned}`, ` Relay: ${status.relayUrl ?? "not provisioned"}`, ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`, ...formatRelayClientStatus(status.relayClient), - ...(!status.desired || status.linkMode !== "managed" + ...(!status.desired ? [] - : status.reachability === null + : publishOnly ? [ "", - "No running server answered, so reachability was not checked. Check the background service with `t3 service status`.", + "This link only publishes agent activity. Remote access through T3 Connect is off, so other devices need their own route to this machine, such as LAN or Tailscale.", ] - : status.reachability.status === "reachable" + : status.reachability === null ? [ "", - `✓ Other devices can reach this machine · ${formatConnectReachable(status.reachability)}`, + "No running server answered, so reachability was not checked. Check the background service with `t3 service status`.", ] - : ["", formatReachabilityFailure(status.reachability)]), + : status.reachability.status === "reachable" + ? [ + "", + `✓ Other devices can reach this machine · ${formatConnectReachable(status.reachability)}`, + ] + : ["", formatReachabilityFailure(status.reachability)]), ...(nextStep ? ["", `Next: ${nextStep}`] : []), ].join("\n"); } diff --git a/apps/server/src/cli/environment.ts b/apps/server/src/cli/environment.ts index 51ce21339e8b..7d7e35b0bea2 100644 --- a/apps/server/src/cli/environment.ts +++ b/apps/server/src/cli/environment.ts @@ -104,7 +104,7 @@ const environmentLinkCommand = Command.make("link", { ), }).pipe( Command.withDescription( - "Link another T3 Code environment, so agents here can work there. It shows up there under Settings → Connections, where it can be revoked.", + "Link another T3 Code environment, so agents here can work there. It shows up there under Authorized clients in Settings → Sharing & access, where it can be revoked.", ), Command.withHandler((flags) => runWithPeerLinks(flags, (links) => @@ -145,7 +145,7 @@ const environmentUnlinkCommand = Command.make("unlink", { ), }).pipe( Command.withDescription( - "Forget a linked environment here. Revoke it in that environment's Connections to end its session there too.", + "Forget a linked environment here. Revoke it in that environment's Sharing & access to end its session there too.", ), Command.withHandler((flags) => runWithPeerLinks(flags, (links) => @@ -153,7 +153,7 @@ const environmentUnlinkCommand = Command.make("unlink", { const removed = yield* links.unlink(EnvironmentId.make(flags.environmentId)); yield* Console.log( removed - ? `Forgot ${flags.environmentId}. Revoke it in that environment's Connections to end its session there.\n` + ? `Forgot ${flags.environmentId}. Revoke it in that environment's Sharing & access to end its session there.\n` : `No linked environment ${flags.environmentId}.\n`, ); }), diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index da87d1cc9280..0e58caf4bdcc 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,4 +1,10 @@ -import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; +import type { + AuthClientMetadata, + AuthClientSession, + AuthConnectDevice, + AuthConnectDeviceRequest, + AuthPairingLink, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts"; @@ -190,3 +196,45 @@ export function formatSessionList( .join(`${newline}${newline}`) + newline ); } + +export function formatConnectDeviceList( + devices: { + readonly waiting: ReadonlyArray; + readonly trusted: ReadonlyArray; + }, + options?: { + readonly json?: boolean; + }, +): string { + if (options?.json) { + return `${JSON.stringify( + { + waiting: devices.waiting, + trusted: devices.trusted.map((device) => ({ + ...device, + approvedAt: toIsoString(device.approvedAt), + })), + }, + null, + 2, + )}${newline}`; + } + const describe = (client: AuthClientMetadata | undefined) => + client ? formatClientMetadata(client) : "unlabeled client"; + return ( + [ + devices.waiting.length === 0 + ? "No devices are waiting for approval." + : "Waiting for approval:", + ...devices.waiting.map( + (request) => ` ${request.verificationCode} ${describe(request.client)}`, + ), + "", + devices.trusted.length === 0 ? "No trusted T3 Connect devices." : "Trusted:", + ...devices.trusted.map( + (device) => + ` ${device.verificationCode} ${describe(device.client)} since ${toIsoString(device.approvedAt)}`, + ), + ].join(newline) + newline + ); +} diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 57d54bef2af9..6c33460d1afd 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -62,6 +62,7 @@ import { pendingServiceUpdateExists } from "./updateHandoff.ts"; import { managedTunnelStartupAction } from "./managedTunnelStartup.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as ServerSettings from "../serverSettings.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new ServerSecretStore.SecretStorePersistError({ @@ -183,6 +184,7 @@ describe("releaseManagedTunnelOnShutdown", () => { CloudLink.layer.pipe( Layer.provide(EnvironmentKeys.layer), Layer.provide(ManagedPolicy.layerTest()), + Layer.provide(ServerSettings.ServerSettingsService.layerTest()), ), ), Effect.provideService( diff --git a/apps/server/src/cloud/CloudLink.test.ts b/apps/server/src/cloud/CloudLink.test.ts index 2bf1fce17a54..bb5072f1f462 100644 --- a/apps/server/src/cloud/CloudLink.test.ts +++ b/apps/server/src/cloud/CloudLink.test.ts @@ -41,6 +41,7 @@ import { RELAY_URL_SECRET, } from "./config.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as ServerSettings from "../serverSettings.ts"; const encode = (value: string) => new TextEncoder().encode(value); const decode = (value: Uint8Array | undefined) => new TextDecoder().decode(value); @@ -94,6 +95,9 @@ const withService = ( readonly link: CloudLink.CloudLink["Service"]; readonly stored: Map; readonly relayCalls: Array; + readonly connectCredentialRequests: Array< + Parameters[0] + >; }) => Effect.Effect, ) => Effect.gen(function* () { @@ -109,6 +113,9 @@ const withService = ( ), ]); const relayCalls: Array = []; + const connectCredentialRequests: Array< + Parameters[0] + > = []; const fetch: typeof globalThis.fetch = Object.assign( (_input: Parameters[0], init?: RequestInit) => { const payload = JSON.parse(String(init?.body)) as { holdWebhooksWhileOffline: boolean }; @@ -124,6 +131,7 @@ const withService = ( { preconnect: () => {} }, ); const layerDependencies = Layer.mergeAll( + ServerSettings.ServerSettingsService.layerTest(), Layer.mock(ServerSecretStore.ServerSecretStore)({ get: (name) => (options.failActivityRead && name === PUBLISH_AGENT_ACTIVITY_SECRET) || @@ -160,6 +168,11 @@ const withService = ( }), Layer.mock(EnvironmentAuth.EnvironmentAuth)({ isSessionActive: () => Effect.succeed(true), + issueConnectCredential: (input) => + Effect.sync(() => { + connectCredentialRequests.push(input); + return { credential: "pairing-credential", expiresAt: DateTime.makeUnsafe(120_000) }; + }), ...options.auth, }), Layer.mock(CliTokenManager.CloudCliTokenManager)({}), @@ -181,6 +194,7 @@ const withService = ( link, stored, relayCalls, + connectCredentialRequests, }); }).pipe( Effect.provide( @@ -325,14 +339,6 @@ it.effect("an update the client walks away from still finishes", () => { ); }); -const issuedLink = { - id: "link", - credential: "credential", - scopes: [], - subject: "cloud-connect", - expiresAt: DateTime.makeUnsafe(120_000), -} as unknown as EnvironmentAuth.IssuedPairingLink; - it.effect.each([ { name: "no stored mode", initial: [], scopes: AuthStandardClientScopes }, { @@ -350,46 +356,26 @@ it.effect.each([ initial: [[CLOUD_SHARING_MODE, "guest"]] as const, scopes: AuthViewOnlyClientScopes, }, -])("a device minting under $name gets the matching scopes", ({ initial, scopes }) => { - const minted: Array> = []; - return withService( - { - initialSecrets: initial, - auth: { - createPairingLink: (input) => { - minted.push(input?.scopes ?? []); - return Effect.succeed(issuedLink); - }, - }, - }, - ({ link }) => - Effect.gen(function* () { - yield* link.mintCredential(yield* mintRequest); - assert.deepEqual(minted, [scopes]); - }), - ); -}); +])("a device minting under $name gets the matching scopes", ({ initial, scopes }) => + withService({ initialSecrets: initial }, ({ link, connectCredentialRequests }) => + Effect.gen(function* () { + yield* link.mintCredential(yield* mintRequest); + assert.deepEqual(connectCredentialRequests, [ + { proofKeyThumbprint: "thumbprint", requireApproval: false, scopes }, + ]); + }), + ), +); -it.effect("an unreadable mode refuses the mint instead of granting full access", () => { - const minted: Array = []; - return withService( - { - failSecretRead: CLOUD_SHARING_MODE, - auth: { - createPairingLink: (input) => { - minted.push(input); - return Effect.succeed(issuedLink); - }, - }, - }, - ({ link }) => - Effect.gen(function* () { - const failure = yield* link.mintCredential(yield* mintRequest).pipe(Effect.flip); - assert.equal(failure._tag, "CloudLinkInternalError"); - assert.deepEqual(minted, []); - }), - ); -}); +it.effect("an unreadable mode refuses the mint instead of granting full access", () => + withService({ failSecretRead: CLOUD_SHARING_MODE }, ({ link, connectCredentialRequests }) => + Effect.gen(function* () { + const failure = yield* link.mintCredential(yield* mintRequest).pipe(Effect.flip); + assert.equal(failure._tag, "CloudLinkInternalError"); + assert.deepEqual(connectCredentialRequests, []); + }), + ), +); it.effect("a mode change stores the mode before signing anyone out", () => { const seenAtRevoke: Array = []; @@ -521,14 +507,14 @@ it.effect("a mint in progress finishes before a mode change can sign anyone out" return withService( { auth: { - createPairingLink: (input) => + issueConnectCredential: (input) => Effect.promise(() => { started(); return released; }).pipe( Effect.map(() => { - events.push(`minted:${String(input?.scopes?.length)}`); - return issuedLink; + events.push(`minted:${String(input.scopes?.length)}`); + return { credential: "credential", expiresAt: DateTime.makeUnsafe(120_000) }; }), ), revokeOtherAccess: () => Effect.sync(() => void events.push("revoked")), @@ -552,7 +538,6 @@ it.effect("a mint in progress finishes before a mode change can sign anyone out" }); it.effect("a mint during a mode change waits and gets the new scopes", () => { - const minted: Array> = []; let release!: () => void; let started!: () => void; const released = new Promise((resolve) => { @@ -564,11 +549,6 @@ it.effect("a mint during a mode change waits and gets the new scopes", () => { return withService( { auth: { - createPairingLink: (input) => - Effect.sync(() => { - minted.push(input?.scopes ?? []); - return issuedLink; - }), revokeOtherAccess: () => Effect.promise(() => { started(); @@ -576,7 +556,7 @@ it.effect("a mint during a mode change waits and gets the new scopes", () => { }), }, }, - ({ link }) => + ({ link, connectCredentialRequests }) => Effect.gen(function* () { const request = yield* mintRequest; const change = yield* link @@ -585,11 +565,14 @@ it.effect("a mint during a mode change waits and gets the new scopes", () => { yield* Effect.promise(() => atRevoke); const mint = yield* link.mintCredential(request).pipe(Effect.forkChild); yield* Effect.yieldNow; - assert.deepEqual(minted, []); + assert.deepEqual(connectCredentialRequests, []); release(); yield* Fiber.join(change); yield* Fiber.join(mint); - assert.deepEqual(minted, [AuthViewOnlyClientScopes]); + assert.deepEqual( + connectCredentialRequests.map((request) => request.scopes), + [AuthViewOnlyClientScopes], + ); }), ); }); @@ -602,6 +585,7 @@ it.effect("unlinks on startup and refuses to link when the policy turns Connect ]); const appliedConfigs: Array = []; const layerDependencies = Layer.mergeAll( + ServerSettings.ServerSettingsService.layerTest(), Layer.mock(ServerSecretStore.ServerSecretStore)({ get: (name) => Effect.succeed(Option.fromNullishOr(stored.get(name))), set: (name, value) => Effect.sync(() => void stored.set(name, value)), diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 1d4076b538a7..3d623430a368 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -105,6 +105,7 @@ import * as ConnectReachability from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import * as ManagedLimits from "../policy/managedLimits.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { relayUrlConfig } from "./publicConfig.ts"; import { filterRelayResponse, @@ -537,6 +538,7 @@ const make = Effect.gen(function* () { const endpointRuntime = yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime; const reachability = yield* ConnectReachability.ConnectReachability; const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const serverSettings = yield* ServerSettings.ServerSettingsService; const cliTokenManager = yield* CliTokenManager.CloudCliTokenManager; const httpClient = yield* HttpClient.HttpClient; const httpServer = yield* HttpServer.HttpServer; @@ -1639,18 +1641,19 @@ const make = Effect.gen(function* () { return yield* new CloudLinkProofReplayedError({ request: "mint" }); } + const settings = yield* serverSettings.getSettings.pipe( + Effect.catch(internalError("issue-credential")), + ); const issued = yield* sharingModeLock.withPermits(1)( secrets.get(CLOUD_SHARING_MODE).pipe( Effect.flatMap((mode) => - environmentAuth.createPairingLink({ + environmentAuth.issueConnectCredential({ + proofKeyThumbprint: proof.clientProofKeyThumbprint, + requireApproval: settings.requireConnectDeviceApproval, scopes: Option.isSome(mode) && decodeSharingMode(mode.value) === "view-only" ? AuthViewOnlyClientScopes : AuthStandardClientScopes, - subject: "cloud-connect", - ttl: Duration.minutes(2), - label: "T3 Connect connect", - proofKeyThumbprint: proof.clientProofKeyThumbprint, }), ), ), diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 8572edfc5207..3b3a711ed725 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -30,6 +30,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as RelayClientLedger from "./RelayClientLedger.ts"; const layerRelayClientAt = (executablePath: string) => Layer.succeed( @@ -77,6 +78,7 @@ const runtimeDependencies = ( Layer.succeed(HttpClient.HttpClient, httpClient), relayClientLayer, secretStoreLayer, + RelayClientLedger.layerTest, ServerConfig.layerTest(process.cwd(), { prefix: "t3-managed-endpoint-" }), ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); @@ -467,6 +469,51 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect("still owns a relay client when activation is interrupted while recording it", () => + Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const recording = yield* Deferred.make(); + const recorded = yield* Deferred.make(); + const ledger = Layer.succeed( + RelayClientLedger.RelayClientLedger, + RelayClientLedger.RelayClientLedger.of({ + track: () => + Deferred.succeed(recording, undefined).pipe( + Effect.andThen(Deferred.await(recorded)), + Effect.as(Effect.void), + ), + }), + ); + const context = yield* Layer.build( + ManagedEndpointRuntime.layer.pipe( + Layer.provide(Layer.merge(runtimeDependencies(fixture.spawner), ledger)), + ), + ); + const runtime = yield* Effect.service( + ManagedEndpointRuntime.CloudManagedEndpointRuntime, + ).pipe(Effect.provide(context)); + + const activation = yield* Effect.forkChild(runtime.applyConfig(tunnelConfig)); + yield* Deferred.await(recording); + const interrupting = yield* Effect.forkChild(Fiber.interrupt(activation), { + startImmediately: true, + }); + yield* Deferred.succeed(recorded, undefined); + yield* Fiber.join(interrupting); + + // The handed-over connector is supervised: its exit starts a replacement. + expect(yield* Queue.take(fixture.started)).toBe(0); + yield* Deferred.succeed(fixture.exits[0]!, ChildProcessSpawner.ExitCode(1)); + expect(yield* Queue.take(fixture.started)).toBe(1); + + yield* runtime.applyConfig(null); + expect(fixture.killed).toEqual([800, 801]); + }).pipe( + Effect.provideService(HostProcess.Environment, {}), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ), + ); + it.effect("preserves an explicit transport choice despite repeated QUIC stalls", () => Effect.gen(function* () { const fixture = yield* makeTransportSpawner({ transport: "quic" }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index f1684efaa536..add2332045a1 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -28,6 +28,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ManagedLimits from "../policy/managedLimits.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as RelayClientLedger from "./RelayClientLedger.ts"; export type CloudManagedEndpointRuntimeStatus = | { @@ -213,6 +214,7 @@ const stopConnector = (connector: ActiveConnector | null) => export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const relayClient = yield* RelayClient.RelayClient; + const relayClientLedger = yield* RelayClientLedger.RelayClientLedger; const secrets = yield* ServerSecretStore.ServerSecretStore; const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); // Without T3 Connect no connector ever runs, whoever asks for one. @@ -760,6 +762,12 @@ export const make = Effect.gen(function* () { const http2 = yield* Ref.get(http2Ref); const connectorScope = yield* Scope.make("sequential"); + // Added before the spawn so it runs after the spawner's finalizer has stopped the client. + let forgetRelayClient: Effect.Effect = Effect.void; + yield* Scope.addFinalizer( + connectorScope, + Effect.suspend(() => forgetRelayClient), + ); const child = yield* spawner .spawn( ChildProcess.make( @@ -825,22 +833,28 @@ export const make = Effect.gen(function* () { } if (!("status" in child)) { - const connector = { - child, - executable, - scope: connectorScope, - configKey: nextConfigKey, - config, - startedAtMillis: yield* Clock.currentTimeMillis, - registered: yield* Deferred.make(), - metricsAddress: yield* Deferred.make(), - protocol: yield* Ref.make(null), - } satisfies ActiveConnector; - yield* Ref.set(activeRef, connector); - yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); - yield* Effect.forkIn(superviseConnector(connector), connectorScope); - yield* Effect.forkIn(watchConnectorRegistration(connector), connectorScope); - yield* Effect.forkIn(watchConnectorReadiness(connector), connectorScope); + // An interrupt before activeRef owns a supervised connector would leave its client open or unwatched. + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + forgetRelayClient = yield* relayClientLedger.track(Number(child.pid)); + const connector = { + child, + executable, + scope: connectorScope, + configKey: nextConfigKey, + config, + startedAtMillis: yield* Clock.currentTimeMillis, + registered: yield* Deferred.make(), + metricsAddress: yield* Deferred.make(), + protocol: yield* Ref.make(null), + } satisfies ActiveConnector; + yield* Ref.set(activeRef, connector); + yield* Effect.forkIn(restore(observeConnectorOutput(connector)), connectorScope); + yield* Effect.forkIn(restore(superviseConnector(connector)), connectorScope); + yield* Effect.forkIn(restore(watchConnectorRegistration(connector)), connectorScope); + yield* Effect.forkIn(restore(watchConnectorReadiness(connector)), connectorScope); + }), + ); return { status: "running", providerKind: "cloudflare_tunnel", diff --git a/apps/server/src/cloud/RelayClientLedger.test.ts b/apps/server/src/cloud/RelayClientLedger.test.ts new file mode 100644 index 000000000000..deee9ddc46e6 --- /dev/null +++ b/apps/server/src/cloud/RelayClientLedger.test.ts @@ -0,0 +1,112 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import * as RelayClientLedger from "./RelayClientLedger.ts"; + +const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const spawnSleep = Effect.acquireRelease( + Effect.gen(function* () { + const child = NodeChildProcess.spawn("sleep", ["600"], { stdio: "ignore" }); + const pid = child.pid; + if (pid === undefined) return yield* Effect.die("spawn failed"); + const exited = yield* Deferred.make(); + child.once("exit", () => Deferred.doneUnsafe(exited, Effect.void)); + return { + pid, + kill: Effect.sync(() => child.kill("SIGKILL")).pipe(Effect.andThen(Deferred.await(exited))), + }; + }), + ({ kill }) => kill, +); + +const entryNames = (stateDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs + .readDirectory(path.join(stateDir, "relay-clients")) + .pipe(Effect.orElseSucceed(() => [])); + }); + +const hostPlatform = HostProcess.Platform.defaultValue(); +// procps accepts the same `ps` flags as macOS, so Linux also covers the macOS path. +const observedPlatforms: ReadonlyArray = + hostPlatform === "linux" ? ["linux", "darwin"] : hostPlatform === "darwin" ? ["darwin"] : []; + +describe.each(observedPlatforms)("RelayClientLedger observing as %s", (platform) => { + const run = (effect: Effect.Effect) => + effect.pipe( + Effect.scoped, + Effect.provideService(HostProcess.Platform, platform), + Effect.provide(NodeServices.layer), + ); + + it.live("stops a client left by a dead server and keeps one whose server still runs", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-ledger-" }); + const deadOwner = yield* spawnSleep; + const liveOwner = yield* spawnSleep; + const orphan = yield* spawnSleep; + const adopted = yield* spawnSleep; + + const dead = yield* RelayClientLedger.make({ stateDir, ownerPid: deadOwner.pid }); + yield* Effect.asVoid(dead.track(orphan.pid)); + const live = yield* RelayClientLedger.make({ stateDir, ownerPid: liveOwner.pid }); + yield* Effect.asVoid(live.track(adopted.pid)); + yield* deadOwner.kill; + + const restarted = yield* RelayClientLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(isAlive(orphan.pid)).toBe(false); + expect(isAlive(adopted.pid)).toBe(true); + expect(yield* entryNames(stateDir)).toEqual([`${adopted.pid}.json`]); + }), + ), + ); + + it.live("never signals a recycled pid and forgets the stale record", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-ledger-" }); + const deadOwner = yield* spawnSleep; + const unrelated = yield* spawnSleep; + + const dead = yield* RelayClientLedger.make({ stateDir, ownerPid: deadOwner.pid }); + yield* Effect.asVoid(dead.track(unrelated.pid)); + yield* deadOwner.kill; + // The recorded client has exited and its pid now belongs to another process. + const entryPath = path.join(stateDir, "relay-clients", `${unrelated.pid}.json`); + const entry = JSON.parse(yield* fs.readFileString(entryPath)); + entry.client.startTime = "0"; + yield* fs.writeFileString(entryPath, JSON.stringify(entry)); + + const restarted = yield* RelayClientLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(isAlive(unrelated.pid)).toBe(true); + expect(yield* entryNames(stateDir)).toEqual([]); + }), + ), + ); +}); diff --git a/apps/server/src/cloud/RelayClientLedger.ts b/apps/server/src/cloud/RelayClientLedger.ts new file mode 100644 index 000000000000..dfd95e2df1ba --- /dev/null +++ b/apps/server/src/cloud/RelayClientLedger.ts @@ -0,0 +1,258 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +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/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +const ProcessIdentity = Schema.Struct({ + pid: Schema.Int, + startTime: Schema.String, + command: Schema.optional(Schema.String), +}); +type ProcessIdentity = typeof ProcessIdentity.Type; + +const RelayClientEntry = Schema.Struct({ + version: Schema.Literal(1), + client: ProcessIdentity, + // A process may rewrite its own argv, so the owner is identified by pid and start time. + owner: ProcessIdentity, +}); +type RelayClientEntry = typeof RelayClientEntry.Type; +const RelayClientEntryJson = Schema.fromJsonString(RelayClientEntry); +const decodeEntry = Schema.decodeUnknownOption(RelayClientEntryJson); +const encodeEntry = Schema.encodeEffect(RelayClientEntryJson); + +/** Remembers running relay clients so a server can stop the ones a crashed server left behind. */ +export class RelayClientLedger extends Context.Service< + RelayClientLedger, + { + /** Records a started relay client; the returned effect forgets it once it has stopped. */ + readonly track: (pid: number) => Effect.Effect>; + } +>()("t3/cloud/RelayClientLedger") {} + +const ENTRY_DIRECTORY = "relay-clients"; +const ENTRY_FILE = /^\d+\.json$/; +const STOP_POLL_INTERVAL = "50 millis"; +const STOP_POLL_ATTEMPTS = 40; + +interface ObservedProcess { + readonly startTime: string; + readonly command: string; + readonly zombie: boolean; +} + +// ps lstart needs C locale and UTC for stable parsing. +const DARWIN_PS_LINE = /^\s*(\d+)\s+(\S+)\s+(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4})\s+(.*)$/; + +const processExists = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; + } +}; + +const signal = (pid: number, name: NodeJS.Signals) => { + try { + process.kill(pid, name); + } catch {} +}; + +export const make = Effect.fn("RelayClientLedger.make")(function* (input: { + readonly stateDir: string; + readonly ownerPid?: number; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcess.Platform; + const directory = path.join(input.stateDir, ENTRY_DIRECTORY); + const recordable = platform === "linux" || platform === "darwin"; + + const observeLinux = Effect.fnUntraced(function* (pid: number) { + const stat = yield* fs.readFileString(`/proc/${pid}/stat`); + const fields = stat + .slice(stat.lastIndexOf(")") + 2) + .trim() + .split(/\s+/); + const startTime = fields[19]; + if (startTime === undefined) return undefined; + const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`); + return { + startTime, + command: cmdline.replace(/\0$/, "").replaceAll("\0", " "), + zombie: fields[0] === "Z", + } satisfies ObservedProcess; + }); + + const observeDarwin = (pid: number) => + spawner + .string( + ChildProcess.make("/bin/ps", ["-ww", "-o", "pid=,stat=,lstart=,args=", "-p", String(pid)], { + env: { LC_ALL: "C", TZ: "UTC" }, + extendEnv: true, + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.timeout("5 seconds"), + Effect.map((output) => { + for (const line of output.split("\n")) { + const match = DARWIN_PS_LINE.exec(line); + if (match === null || Number(match[1]) !== pid) continue; + return { + zombie: match[2]!.startsWith("Z"), + startTime: match[3]!, + command: match[4]!.trimEnd(), + } satisfies ObservedProcess; + } + return undefined; + }), + ); + + const observe = (pid: number): Effect.Effect => { + if (platform === "linux") return observeLinux(pid).pipe(Effect.orElseSucceed(() => undefined)); + if (platform === "darwin") + return observeDarwin(pid).pipe(Effect.orElseSucceed(() => undefined)); + return Effect.undefined; + }; + + // "unknown" means the pid exists but could not be read, so nothing may be concluded about it. + const compare = (identity: ProcessIdentity) => + observe(identity.pid).pipe( + Effect.map((observed): "same" | "gone" | "unknown" => { + if (observed === undefined) return processExists(identity.pid) ? "unknown" : "gone"; + return !observed.zombie && + observed.startTime === identity.startTime && + (identity.command === undefined || observed.command === identity.command) + ? "same" + : "gone"; + }), + ); + + const identify = (pid: number) => + observe(pid).pipe( + Effect.map((observed) => + observed === undefined || observed.zombie + ? undefined + : { pid, startTime: observed.startTime, command: observed.command }, + ), + ); + + const ownerProcess = yield* identify(input.ownerPid ?? process.pid); + const owner = + ownerProcess === undefined + ? undefined + : { pid: ownerProcess.pid, startTime: ownerProcess.startTime }; + + const track: RelayClientLedger["Service"]["track"] = Effect.fn("RelayClientLedger.track")( + function* (pid) { + if (!recordable) return Effect.void; + const client = yield* identify(pid); + if (owner === undefined || client?.command === undefined) { + yield* Effect.logWarning( + "Could not record the relay client; it will keep running if this server crashes", + { pid }, + ); + return Effect.void; + } + const entryPath = path.join(directory, `${pid}.json`); + yield* writeFileStringAtomically({ + filePath: entryPath, + contents: yield* encodeEntry({ version: 1, client, owner }), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + return fs + .remove(entryPath, { force: true }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not forget a stopped relay client", { cause }), + ), + ); + }, + Effect.catchCause((cause) => + Effect.logWarning("Could not record the relay client", { cause }).pipe( + Effect.as(Effect.void), + ), + ), + ); + + // Signals only a process that still has the recorded pid, start time and command line, + // so a recycled pid is never touched. Returns whether the client is gone. + const stopOrphan = Effect.fn("RelayClientLedger.stopOrphan")(function* (client: ProcessIdentity) { + let state = yield* compare(client); + if (state !== "same") return state === "gone"; + yield* Effect.logInfo("Stopping a relay client left by a previous T3 Code server", { + pid: client.pid, + }); + for (const name of ["SIGTERM", "SIGKILL"] as const) { + signal(client.pid, name); + for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && state === "same"; attempt++) { + yield* Effect.sleep(STOP_POLL_INTERVAL); + state = yield* compare(client); + } + if (state !== "same") break; + } + return state === "gone"; + }); + + const reapEntry = Effect.fn("RelayClientLedger.reapEntry")( + function* (entryPath: string) { + const entry = decodeEntry(yield* fs.readFileString(entryPath)); + if (Option.isSome(entry)) { + if ((yield* compare(entry.value.owner)) !== "gone") return; + if (!(yield* stopOrphan(entry.value.client))) return; + } + yield* fs.remove(entryPath, { force: true }); + }, + (effect, entryPath) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not clean up a recorded relay client", { entryPath, cause }), + ), + ), + ); + + const reapOrphans = Effect.gen(function* () { + if (!recordable) return; + const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + names.filter((name) => ENTRY_FILE.test(name)), + (name) => reapEntry(path.join(directory, name)), + { concurrency: "unbounded", discard: true }, + ); + }); + + return { track, reapOrphans }; +}); + +// Reaping finishes before any connector starts, so exactly one serves the tunnel. +export const layer = (input: { readonly stateDir: string }) => + Layer.effect( + RelayClientLedger, + Effect.gen(function* () { + const ledger = yield* make(input); + yield* ledger.reapOrphans; + return RelayClientLedger.of({ track: ledger.track }); + }), + ); + +export const layerTest = Layer.succeed( + RelayClientLedger, + RelayClientLedger.of({ + track: Effect.fn("RelayClientLedger.track")(function () { + return Effect.succeed(Effect.void); + }), + }), +); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index cd4921ac452b..4d89805bb19d 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -217,6 +217,8 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( lingerStartsManager: boolean; enabled: boolean; active: boolean; + /** systemd refuses starts until `reset-failed`, as after `StartLimitBurst` trips. */ + startLimitHit: boolean; /** A Windows launcher: it holds its lock until its task ends. */ launcher?: { readonly close: () => void }; /** Task Scheduler's own copy of the task. */ @@ -229,6 +231,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( lingerStartsManager: true, enabled: true, active: true, + startLimitHit: false, }; const runner = ProcessRunner.ProcessRunner.of({ run: Effect.fn("test.run_boot_service_command")(function* ( @@ -238,7 +241,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( commands.push(command); invocations.push(input); timeouts.set(command, input.timeout); - const failed = command === control.failCommand; + if (command === "systemctl --user reset-failed t3code.service") control.startLimitHit = false; + const refusedStart = control.startLimitHit && command.startsWith("systemctl --user restart "); + // `--no-block` only queues the job, so systemd reports success even when it refuses it. + const failed = + command === control.failCommand || + (refusedStart && command === "systemctl --user restart t3code.service"); if (!failed && command.startsWith("loginctl enable-linger --no-ask-password ")) { control.linger = "yes"; control.managerReachable ||= control.lingerStartsManager; @@ -257,7 +265,8 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( if (input.args[0] === "/Delete") delete control.task; if (input.args[0] === "/End") control.launcher?.close(); } - if (!failed && command === "systemctl --user restart t3code.service") control.active = true; + if (!refusedStart && !failed && command.startsWith("systemctl --user restart ")) + control.active = true; // The manager loads units only from its own unit directory. const unitOutsideManager = command === "systemctl --user enable t3code.service" && @@ -848,7 +857,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { ), ).toEqual( platform === "linux" - ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"] + ? [ + "systemctl --user stop t3code.service", + "systemctl --user reset-failed t3code.service", + "systemctl --user restart t3code.service", + ] : [ "launchctl bootout --wait gui/501/com.t3tools.t3code.service", `launchctl bootstrap gui/501 ${plan.unitPath}`, @@ -1177,6 +1190,7 @@ process.disconnect(); "systemctl --user is-active t3code.service", "systemctl --user stop t3code.service", "repair", + "systemctl --user reset-failed t3code.service", "systemctl --user restart t3code.service", ]); expect(control.active).toBe(true); @@ -1304,6 +1318,7 @@ process.disconnect(); expect(commands).toEqual([ "systemctl --user daemon-reload", "systemctl --user enable t3code.service", + "systemctl --user reset-failed t3code.service", "systemctl --user restart --no-block t3code.service", ]); expect(outcome).toBe("queued"); @@ -1312,6 +1327,25 @@ process.disconnect(); }), ); + // regression: #36319 + it.effect("starts a service whose systemd start limit tripped after repairing it", () => + Effect.gen(function* () { + const { service, control, makeService } = yield* makeHarness(); + yield* service.install(); + control.active = false; + control.startLimitHit = true; + + yield* service.install(); + expect(control.active).toBe(true); + + control.active = false; + control.startLimitHit = true; + const inside = yield* makeService(undefined, "1.2.3", undefined, true); + expect(yield* inside.restart).toBe("queued"); + expect(control.active).toBe(true); + }), + ); + it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); @@ -1328,6 +1362,7 @@ process.disconnect(); ).toEqual([ "systemctl --user stop t3code.service", "systemctl --user daemon-reload", + "systemctl --user reset-failed t3code.service", "systemctl --user restart t3code.service", ]); }), @@ -1361,6 +1396,7 @@ process.disconnect(); ), ).toEqual([ "systemctl --user stop t3code.service", + "systemctl --user reset-failed t3code.service", "systemctl --user restart t3code.service", ]); } diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index bea0f8de6ae8..0a884948a97a 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -314,6 +314,13 @@ function systemdManager(input: { readonly unitDir: string; }): BootServiceManager { const unitPath = input.path.join(input.unitDir, BOOT_SERVICE_UNIT_FILE); + // Once StartLimitBurst trips, systemd refuses every start until the failed state is cleared. + const resetFailed = { + step: "clearing the service's failed state", + optional: true, + command: "systemctl", + args: ["--user", "reset-failed", BOOT_SERVICE_UNIT_FILE], + }; const prepareActivation = [ { step: "reloading systemd user units", @@ -325,6 +332,7 @@ function systemdManager(input: { command: "systemctl", args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], }, + resetFailed, ]; return { kind: "systemd", @@ -347,6 +355,7 @@ function systemdManager(input: { }, ], restart: [ + resetFailed, { step: "restarting the service after a failed update", command: "systemctl", diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 85dee57d441e..50f11d8ca25c 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -39,6 +39,7 @@ import * as ConnectHttp from "./http.ts"; import * as ConnectReachability from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as ServerSettings from "../serverSettings.ts"; const DESKTOP_BOOTSTRAP_TOKEN = "test-desktop-bootstrap-token"; const NON_LOOPBACK_ORIGIN = "http://172.24.162.233:3773"; @@ -79,6 +80,7 @@ const layerLinkProofRoutes = HttpApiBuilder.layer(LinkProofTestApi).pipe( Layer.provide([AuthHttp.layer, ConnectHttp.layer]), Layer.provide(AuthHttp.layerAuthenticatedAuth), Layer.provide(CloudLink.layer), + Layer.provide(ServerSettings.ServerSettingsService.layerTest()), Layer.provide(EnvironmentKeys.layer), Layer.provide( Layer.mock(ConnectReachability.ConnectReachability)({ latest: Effect.succeedNone }), diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index e133b4f9db36..5f7cef54f527 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodePath from "@effect/platform-node/NodePath"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -45,7 +47,8 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" }); - const hub = { running: true, spawnFails: false }; + const hub = { running: true, spawnFails: false, cleanup: Effect.void }; + const daemonStopRequested = yield* Deferred.make>(); const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); const runs: Array = []; @@ -80,20 +83,22 @@ const agentFixture = Effect.fn("agentFixture")(function* ( description: "Hub entry missing", }), ) - : Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.never, - isRunning: Effect.sync(() => hub.running), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), + : Effect.addFinalizer(() => hub.cleanup).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.never, + isRunning: Effect.sync(() => hub.running), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), ), ), ), @@ -102,7 +107,9 @@ const agentFixture = Effect.fn("agentFixture")(function* ( runs.push(input); return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + : input.args.includes("daemon") + ? Deferred.succeed(daemonStopRequested, input.args).pipe(Effect.as(processOutput(0))) + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); }, }), Effect.provideService( @@ -122,7 +129,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs }; + return { host, fs, baseDir, stateDir, hub, daemonHealthy, daemonStopRequested, runs }; }); describe("AgentDevice startup", () => { @@ -353,6 +360,33 @@ describe("AgentDevice startup", () => { expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("stops the agent daemon while the hub is still shutting down", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { host, hub, stateDir, daemonStopRequested } = yield* agentFixture((stateDir) => + fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345, token: "test-token" }), + ) + .pipe(Effect.orDie, Effect.as(processOutput(0))), + ); + yield* host.ensureAgentReady(() => Effect.void); + const hubExited = yield* Deferred.make(); + hub.cleanup = Deferred.await(hubExited); + const stopping = yield* Effect.forkChild(host.stop); + expect((yield* Deferred.await(daemonStopRequested)).slice(1)).toEqual([ + "daemon", + "stop", + "--state-dir", + stateDir, + ]); + yield* Deferred.succeed(hubExited, undefined); + yield* Fiber.join(stopping); + expect(yield* fs.exists(`${stateDir}/hub.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index c5097509dcad..e657d0dcdfc1 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -870,9 +870,16 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)( Effect.gen(function* () { const running = yield* Ref.getAndSet(runningRef, null); - yield* stopHub(running?.hub); - yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - yield* stopAgentDeviceDaemon; + // Hub cleanup waits for its exit unbounded; it must not strand the detached agent daemon. + yield* Effect.all( + [ + stopHub(running?.hub).pipe( + Effect.andThen(fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore)), + ), + stopAgentDeviceDaemon, + ], + { concurrency: "unbounded", discard: true }, + ); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 912a30f56368..2c79189878e8 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -274,6 +274,7 @@ export const make = Effect.gen(function* () { projectCloneTracking: true, projectOrganization: true, ignoredFileListing: true, + ignoredFileSearch: true, triage: true, oneTimeSchedules: true, freshContext: true, diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index e44f32e60f5a..1e8b538a665a 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -1,4 +1,8 @@ -import { EnvironmentThemeFile } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentThemeFile, + ServerSettingsError, +} from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -12,6 +16,7 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); @@ -30,6 +35,9 @@ const SHARED_THEME: EnvironmentThemeFile = { colors: { canvas: "#eff1f5", accent: "#1e66f5" }, }; +/** The settings mock refuses to make this theme the default, as a managed policy can. */ +const REFUSED_THEME_ID = "refused"; + const withEnvironmentThemes = ( seeds: Readonly>, body: Effect.Effect< @@ -41,6 +49,8 @@ const withEnvironmentThemes = ( | Path.Path | Scope.Scope >, + /** Receives each theme and appearance the service makes the environment default. */ + selected: Array<{ readonly theme?: string; readonly appearance?: string }> = [], ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -56,6 +66,27 @@ const withEnvironmentThemes = ( Effect.provide( EnvironmentTheme.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.provideMerge( + Layer.mock(ServerSettingsService)({ + updateSettings: (patch) => + patch.defaultTheme === REFUSED_THEME_ID + ? Effect.fail( + new ServerSettingsError({ + settingsPath: "settings.json", + operation: "write-managed-setting", + }), + ) + : Effect.sync(() => { + selected.push({ + ...(patch.defaultTheme === undefined ? {} : { theme: patch.defaultTheme }), + ...(patch.defaultAppearance === undefined + ? {} + : { appearance: patch.defaultAppearance }), + }); + return DEFAULT_SERVER_SETTINGS; + }), + }), + ), ), ), ); @@ -144,6 +175,62 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("puts the previous theme back when the new one cannot be selected", () => + withEnvironmentThemes( + { [`${REFUSED_THEME_ID}.json`]: encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const refused = yield* themes + .apply({ themeId: REFUSED_THEME_ID, theme: { ...NIGHTFALL_THEME, name: "Changed" } }) + .pipe(Effect.flip); + assert.strictEqual(refused._tag, "EnvironmentThemeNotSelectedError"); + assert.deepEqual(yield* themes.current, [{ id: REFUSED_THEME_ID, ...NIGHTFALL_THEME }]); + }), + ), + ); + + it.effect("does not replace a theme file it could not put back", () => { + const oversized = JSON.stringify({ ...NIGHTFALL_THEME, padding: "x".repeat(40_000) }); + return withEnvironmentThemes( + { [`${REFUSED_THEME_ID}.json`]: oversized }, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const refused = yield* themes + .apply({ themeId: REFUSED_THEME_ID, theme: NIGHTFALL_THEME }) + .pipe(Effect.flip); + assert.match(refused.message, /could not be backed up/); + assert.strictEqual( + yield* fs.readFileString(`${config.environmentThemesDir}/${REFUSED_THEME_ID}.json`), + oversized, + ); + }), + ); + }); + + it.effect.skipIf(!symlinksSupported)("does not write through a symlink at its staging path", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const outside = path.join(environmentThemesDir, "..", "outside.txt"); + yield* fs.writeFileString(outside, "keep"); + yield* fs.symlink( + outside, + path.join(environmentThemesDir, `look.json.staging-${process.pid}`), + ); + + yield* themes.apply({ themeId: "look", theme: NIGHTFALL_THEME }); + assert.strictEqual(yield* fs.readFileString(outside), "keep"); + assert.deepEqual(yield* themes.current, [{ id: "look", ...NIGHTFALL_THEME }]); + }), + ), + ); + it.effect.skipIf(!symlinksSupported)("ignores a symlinked theme file", () => withEnvironmentThemes( {}, @@ -159,6 +246,97 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("publishes and selects a theme, and refuses unsafe CSS and unknown ids", () => { + const selected: Array<{ readonly theme?: string; readonly appearance?: string }> = []; + return withEnvironmentThemes( + {}, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + // A comment may name an address. Only a rule that loads one is refused. + const css = + "/* from url(https://example.com/guide) */ [data-chat-header] { border-bottom: 1px solid red; }"; + yield* themes.apply({ themeId: "agent-look", theme: { ...NIGHTFALL_THEME, css } }); + assert.deepEqual(yield* themes.current, [{ id: "agent-look", ...NIGHTFALL_THEME, css }]); + + const leaky = yield* themes + .apply({ + themeId: "leaky", + theme: { + ...NIGHTFALL_THEME, + css: 'input[value^="a"] { background: url(https://example.com/a); }', + }, + }) + .pipe(Effect.flip); + assert.match(leaky.message, /loads files/); + const reserved = yield* themes + .apply({ themeId: "dark", theme: NIGHTFALL_THEME }) + .pipe(Effect.flip); + assert.match(reserved.message, /not a valid theme id/); + const unknown = yield* themes.apply({ themeId: "missing" }).pipe(Effect.flip); + assert.match(unknown.message, /not a built-in or published theme/); + yield* themes.apply({ themeId: "grove" }); + yield* themes.apply({ themeId: "grove", appearance: "dark" }); + yield* themes.apply({ appearance: "light" }); + + assert.deepEqual( + (yield* themes.current).map((theme) => theme.id), + ["agent-look"], + ); + // Only a theme that was published or already exists becomes the environment theme. + assert.deepEqual(selected, [ + { theme: "agent-look" }, + { theme: "grove" }, + { theme: "grove", appearance: "dark" }, + { appearance: "light" }, + ]); + }), + selected, + ); + }); + + it.effect("keeps the served theme when an update does not fit the size limit", () => { + const large = (name: string): EnvironmentThemeFile => ({ + ...NIGHTFALL_THEME, + name, + css: `/* ${"x".repeat(19_900)} */`, + }); + return withEnvironmentThemes( + { + ...Object.fromEntries( + Array.from({ length: 9 }, (_, index) => [ + `aa-${index}.json`, + encodeThemeFile(large(`Large ${index}`)), + ]), + ), + "zz-look.json": encodeThemeFile(NIGHTFALL_THEME), + }, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const served = Effect.map(themes.current, (all) => + all.find((theme) => theme.id === "zz-look"), + ); + assert.deepEqual(yield* served, { id: "zz-look", ...NIGHTFALL_THEME }); + + const refused = yield* themes + .apply({ themeId: "zz-look", theme: large("Too much") }) + .pipe(Effect.flip); + assert.match(refused.message, /could not be published/); + assert.deepEqual(yield* served, { id: "zz-look", ...NIGHTFALL_THEME }); + + // A new theme that sorts first fits, and the last large one no longer does. + const before = (yield* themes.current).map((theme) => theme.id); + const displacing = yield* themes + .apply({ themeId: "a0-first", theme: large("Displacing") }) + .pipe(Effect.flip); + assert.match(displacing.message, /could not be published/); + assert.deepEqual( + (yield* themes.current).map((theme) => theme.id), + before, + ); + }), + ); + }); + it.effect("does not charge skipped files against the total size limit", () => withEnvironmentThemes( { diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index 44e50bd10363..b658870f2e96 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -3,20 +3,26 @@ import * as NodeFS from "node:fs"; import { + type DefaultAppearancePreference, EnvironmentTheme, + type ServerSettingsError, EnvironmentThemeFile, EnvironmentThemeId, environmentThemeFileHasColors, } from "@t3tools/contracts"; -import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import { themeCssLoadsRemoteFiles } from "@t3tools/shared/themeCss"; +import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -25,10 +31,14 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; +import * as ServerSettings from "./serverSettings.ts"; const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit( Schema.fromJsonString(EnvironmentThemeFile), ); +const encodeEnvironmentThemeFileJson = Schema.encodeEffect( + fromJsonStringPretty(EnvironmentThemeFile), +); const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); const THEME_FILE_SUFFIX = ".json"; @@ -43,16 +53,86 @@ interface PublishedThemes { readonly themes: ReadonlyArray; } +const INVALID_ID = + "is not a valid theme id. Use lowercase letters, digits, and hyphens, and not a built-in name."; +const NOT_PUBLISHED = "could not be published. The themes directory may be full or not writable."; + +/** A theme the service will not publish or select. `problem` ends the sentence naming it. */ +export class EnvironmentThemeRefusedError extends Schema.TaggedError()( + "EnvironmentThemeRefusedError", + { + themeId: Schema.String, + problem: Schema.String, + known: Schema.optional(Schema.Array(Schema.String)), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const known = this.known ? ` Available: ${this.known.join(", ")}.` : ""; + return `Theme "${this.themeId}" ${this.problem}${known}`; + } +} + +/** The settings write that makes a theme or mode the default failed. */ +export class EnvironmentThemeNotSelectedError extends Schema.TaggedError()( + "EnvironmentThemeNotSelectedError", + { + /** Absent when only the appearance was set. */ + themeId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const subject = this.themeId === undefined ? "The appearance" : `Theme "${this.themeId}"`; + return `${subject} was not applied, because the settings could not be saved.`; + } +} + export class EnvironmentThemeService extends Context.Service< EnvironmentThemeService, { readonly current: Effect.Effect>; + /** The default theme id and the published themes, plus the one named `themeId`. */ + readonly read: (themeId?: string) => Effect.Effect< + { + readonly current: string; + readonly themes: ReadonlyArray; + readonly theme?: EnvironmentTheme; + }, + EnvironmentThemeRefusedError | ServerSettingsError + >; + + /** + * Makes `themeId` the environment theme and `appearance` its mode, so connected clients + * switch to them. With `theme`, first publishes that file under the id, replacing one already + * there. + */ + readonly apply: ( + input: + | { + readonly themeId: string; + readonly theme?: EnvironmentThemeFile | undefined; + readonly appearance?: Exclude | undefined; + } + | { readonly appearance: Exclude }, + ) => Effect.Effect; + /** Subscribes before reading to retain changes during connection. */ readonly streamChanges: Stream.Stream>; } >()("t3/environmentTheme/EnvironmentThemeService") {} +/** True when anything, a dangling symlink included, is at the path. */ +const pathIsTaken = (filePath: string): boolean => { + try { + NodeFS.lstatSync(filePath); + return true; + } catch { + return false; + } +}; + /** Validates and reads one handle; rejects symlinked files, allows symlinked directories. * Uses non-blocking opens against FIFOs; Windows lacks these flags and checks symlinks with lstat. */ export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { @@ -156,6 +236,7 @@ export const readPublishedThemes = Effect.fn(function* (themesDir: string) { const make = Effect.gen(function* () { const { environmentThemesDir } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; + const settings = yield* ServerSettings.ServerSettingsService; // Sequence numbers let subscribers drop queued events older than their initial snapshot. const changes = yield* PubSub.sliding(1); const published = yield* Ref.make({ seq: 0, themes: [] }); @@ -197,8 +278,146 @@ const make = Effect.gen(function* () { Effect.asVoid, ); + // One publish at a time: they share a staging path, and each one checks what is served after it. + const publishSemaphore = yield* Semaphore.make(1); + + /** Callers hold `publishSemaphore`. Succeeds with the effect that undoes this publish. */ + + const publish = Effect.fnUntraced(function* (themeId: string, file: EnvironmentThemeFile) { + if (UNPUBLISHABLE_THEME_IDS.has(themeId)) { + return yield* new EnvironmentThemeRefusedError({ themeId, problem: INVALID_ID }); + } + if (!environmentThemeFileHasColors(file)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "has no colors. Give canvas and accent, or at least one color role.", + }); + } + if (file.css !== undefined && themeCssLoadsRemoteFiles(file.css)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: + "has CSS that loads files. Remove @import, image-set(), and url() that is not data: or #id.", + }); + } + const contents = yield* encodeEnvironmentThemeFileJson(file).pipe( + Effect.mapError( + (cause) => new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED, cause }), + ), + ); + if (Buffer.byteLength(contents) > MAX_THEME_FILE_BYTES) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: `is larger than ${MAX_THEME_FILE_BYTES} bytes.`, + }); + } + + const destination = `${environmentThemesDir}/${themeId}${THEME_FILE_SUFFIX}`; + // The watcher reads only .json names, so it never sees a half-written file. + const staging = `${destination}.staging-${process.pid}`; + // Exclusive create: a link left at the staging path is removed, never written through. + const replace = (next: string) => + fs.remove(staging, { force: true }).pipe( + Effect.andThen(fs.writeFileString(staging, next, { flag: "wx" })), + Effect.andThen(fs.rename(staging, destination)), + Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore)), + ); + + // The guarded reader does not block on a FIFO and does not follow a symlink. + const previous = Option.fromNullOr(readThemeFileGuarded(destination, MAX_THEME_FILE_BYTES)); + // A file it cannot read back, such as one over the size limit, could not be restored. + if (Option.isNone(previous) && pathIsTaken(destination)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "would replace a file that could not be backed up. Use another theme id.", + }); + } + const servedBefore = (yield* refresh).themes.map((theme) => theme.id); + // Puts back what was there, so a failed apply does not take a working theme away. + const revert = Option.match(previous, { + onNone: () => fs.remove(destination, { force: true }), + onSome: replace, + }).pipe(Effect.ignore, Effect.andThen(refresh), Effect.asVoid); + + yield* fs.makeDirectory(environmentThemesDir, { recursive: true }).pipe( + Effect.andThen(replace(contents)), + Effect.mapError( + (cause) => new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED, cause }), + ), + ); + // The reader stops at its count and size limits, so this file can go unserved, or it can + // push another theme out. Both count as a failed publish. + const served = new Set((yield* refresh).themes.map((theme) => theme.id)); + if (served.has(themeId) && servedBefore.every((id) => served.has(id))) return revert; + yield* revert; + return yield* new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED }); + }); + + const apply: EnvironmentThemeService["Service"]["apply"] = Effect.fn( + "EnvironmentThemeService.apply", + )(function* (input) { + const themeId = "themeId" in input ? input.themeId : undefined; + const theme = "theme" in input ? input.theme : undefined; + const { appearance } = input; + if (themeId !== undefined && !isEnvironmentThemeId(themeId)) { + return yield* new EnvironmentThemeRefusedError({ themeId, problem: INVALID_ID }); + } + // Timestamps make clients apply a set again when its value did not change. Theme and + // appearance have one each, so setting one does not undo a later local choice of the other. + const select = Effect.gen(function* () { + const setAt = DateTime.formatIso(yield* DateTime.now); + yield* settings + .updateSettings({ + ...(themeId === undefined ? {} : { defaultTheme: themeId, defaultThemeSetAt: setAt }), + ...(appearance === undefined + ? {} + : { defaultAppearance: appearance, defaultAppearanceSetAt: setAt }), + }) + .pipe(Effect.mapError((cause) => new EnvironmentThemeNotSelectedError({ themeId, cause }))); + }); + // One at a time, so a selection never sees a file that a failed publish then takes back. + // If the theme cannot be selected, the file it replaced comes back. + yield* publishSemaphore.withPermits(1)( + Effect.gen(function* () { + if (themeId === undefined) return yield* select; + if (theme === undefined) { + const known = [...BUILT_IN_THEME_IDS, ...(yield* refresh).themes.map(({ id }) => id)]; + if (!known.includes(themeId)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "is not a built-in or published theme.", + known, + }); + } + return yield* select; + } + const revert = yield* publish(themeId, theme); + yield* select.pipe(Effect.tapError(() => revert)); + }), + ); + }); + + const read: EnvironmentThemeService["Service"]["read"] = Effect.fn( + "EnvironmentThemeService.read", + )(function* (themeId) { + const { themes } = yield* refresh; + const { defaultTheme } = yield* settings.getSettings; + if (themeId === undefined) return { current: defaultTheme, themes }; + const theme = themes.find((candidate) => candidate.id === themeId); + if (theme === undefined) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "is not a published theme.", + known: themes.map(({ id }) => id), + }); + } + return { current: defaultTheme, themes, theme }; + }); + return { current: Effect.map(refresh, (state) => state.themes), + read, + apply, get streamChanges() { return Stream.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 04d825385edb..d3e0d49bdbe5 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2606,6 +2606,91 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("branch PR lookup in a fork checkout follows the branch's own published ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", forkDir]); + yield* runGit(repoDir, [ + "remote", + "add", + "upstream", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + // What a new worktree thread gets: its start point as upstream, nothing published. + yield* runGit(repoDir, ["branch", "--track", "t3code/new-thread", "origin/main"]); + yield* runGit(repoDir, ["branch", "--track", "feature/published", "origin/main"]); + yield* runGit(repoDir, ["branch", "--track", "t3code/pr-300/main", "origin/main"]); + yield* runGit(repoDir, ["push", "origin", "feature/published"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const forkPullRequest = { + baseRefName: "main", + isCrossRepository: true, + headRepositoryNameWithOwner: "contributor/codething-mvp", + headRepositoryOwnerLogin: "contributor", + } as const; + const { service, calls } = createFakeSourceControlProvider({ + prListByHeadSelector: { + "contributor:main": [ + { + ...forkPullRequest, + number: 300, + title: "Fork main", + url: "https://github.com/pingdotgg/codething-mvp/pull/300", + headRefName: "main", + state: "merged", + }, + ], + "feature/published": [ + { + ...forkPullRequest, + number: 224, + title: "Fork feature", + url: "https://github.com/pingdotgg/codething-mvp/pull/224", + headRefName: "feature/published", + state: "open", + }, + ], + }, + }); + const { manager } = yield* makeManager({ + sourceControlProvider: { + ...service, + createsChangeRequestsIn: ({ repository }) => + Effect.succeed(repository === "pingdotgg/codething-mvp"), + }, + }); + + const unpublished = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "t3code/new-thread", + }); + expect(unpublished).toBeNull(); + expect(listedSelectors(calls)).toEqual([]); + + const published = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/published", + }); + expect(published?.number).toBe(224); + + const pullRequestWorktree = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "t3code/pr-300/main", + }); + expect(pullRequestWorktree?.number).toBe(300); + }), + ); + it.effect("branch PR lookup ignores the PR of a local branch the branch tracks", () => 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 aa06c85ad420..af56b1b65b51 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -272,6 +272,11 @@ function resolveHeadRepositoryNameWithOwner(pullRequest: PullRequestHeadRemoteIn return normalizeOptionalString(pullRequest.headRepositoryNameWithOwner); } +// Both names `resolvePullRequestWorktreeLocalBranchName` gives a fork pull request's branch. +function isPullRequestWorktreeLocalBranchName(branch: string): boolean { + return /^t3code[/-]pr-\d+[/-]/u.test(branch); +} + function resolvePullRequestWorktreeLocalBranchName( pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranches: ReadonlySet, @@ -1848,7 +1853,12 @@ export const make = Effect.gen(function* () { }; } } - if (headContext.headBranch === details.branch || headContext.isCrossRepository) { + // A fork's default branch can be a PR head. When that fork is origin it is also where new + // branches start, so only a branch made for a pull request takes it as its head. + const tracksForkHead = + headContext.isCrossRepository && + (headContext.remoteName !== "origin" || isPullRequestWorktreeLocalBranchName(details.branch)); + if (headContext.headBranch === details.branch || tracksForkHead) { return { headContext, lookup: true }; } const upstreamHeadIsBase = diff --git a/apps/server/src/guest/http.test.ts b/apps/server/src/guest/http.test.ts index e4c2402197b5..8452c4758f62 100644 --- a/apps/server/src/guest/http.test.ts +++ b/apps/server/src/guest/http.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { GUEST_MESSAGE_MAX_LENGTH } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { HttpServerRequest } from "effect/http"; @@ -26,3 +27,24 @@ it.effect("keeps a JSON file a guest attaches as a file, not a form field", () = ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("keeps a multi-line form message within the length limit as the guest typed it", () => + Effect.gen(function* () { + // At the limit with LF, over it once each line break is counted as CRLF. + const lines = Array.from({ length: 50 }, () => "x".repeat(79)); + const text = lines.join("\n").padEnd(GUEST_MESSAGE_MAX_LENGTH, "x"); + const form = new FormData(); + form.append("shareId", "share-1"); + form.append("text", text); + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/api/guest/messages?share=share-1", { + method: "POST", + body: form, + }), + ); + const submission = yield* readSubmission.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + ); + assert.strictEqual(submission?.fields.text, text); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/guest/http.ts b/apps/server/src/guest/http.ts index b3e856200c84..c92dfaaa4dc5 100644 --- a/apps/server/src/guest/http.ts +++ b/apps/server/src/guest/http.ts @@ -192,9 +192,12 @@ export const readSubmission = Effect.gen(function* () { }), ), ); - const fields = yield* decodeSubmit({ shareId: parts.shareId, text: parts.text ?? "" }).pipe( - Effect.option, - ); + const text = parts.text ?? ""; + const fields = yield* decodeSubmit({ + shareId: parts.shareId, + // Form data sends every line break as CRLF; the textarea gave the guest LF. + text: typeof text === "string" ? text.replaceAll("\r\n", "\n") : text, + }).pipe(Effect.option); if (fields._tag === "None") return null; const files = parts.file; const uploads: ReadonlyArray = Array.isArray(files) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 5f7df20c738c..d03d7cb6f8da 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -399,6 +399,8 @@ it.layer( expect(response.status).toBe(200); expect(yield* response.text).toBe(nextHtml); expect(response.headers["cache-control"]).toBe("no-cache"); + expect(response.headers["content-security-policy"]).toBe("frame-ancestors 'none'"); + expect(response.headers["x-frame-options"]).toBe("DENY"); expect(response.headers["etag"]).toBeUndefined(); expect(response.headers["last-modified"]).toBeUndefined(); } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 142703540472..bbff00acb6f7 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -564,6 +564,12 @@ const streamStaticFile = (file: FileSystem.File, size: bigint) => }), ); +// The app can approve agents and start threads in one click, so no other site may frame it. +const SPA_FRAME_HEADERS = { + "Content-Security-Policy": "frame-ancestors 'none'", + "X-Frame-Options": "DENY", +}; + const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( function* (immutableBuildAssets: ReadonlySet) { const request = yield* HttpServerRequest.HttpServerRequest; @@ -664,6 +670,7 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( }; const headers: Record = { "Cache-Control": cacheControl, + ...(isHtml ? SPA_FRAME_HEADERS : {}), ...validators(fileInfo), }; const etag = headers.ETag; @@ -700,6 +707,7 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( return HttpServerResponse.uint8Array(bytes, { headers: { "Cache-Control": cacheControl, + ...(isHtml ? SPA_FRAME_HEADERS : {}), ...validators({ ...info, size: ByteSize.bytes(bytes.byteLength) }), }, contentType, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 39f679154bcf..062c51ac6d7c 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -53,6 +53,8 @@ import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts"; import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts"; +import { ThemeToolkit } from "./toolkits/theme/tools.ts"; +import * as ThemeHandlers from "./toolkits/theme/handlers.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; import * as ProjectHandlers from "./toolkits/project/handlers.ts"; import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; @@ -84,6 +86,8 @@ import { DeviceStandardToolkit, } from "./toolkits/device/tools.ts"; import * as HtmlHandlers from "./toolkits/html/handlers.ts"; +import { MessageToolkit } from "./toolkits/message/tools.ts"; +import * as MessageHandlers from "./toolkits/message/handlers.ts"; import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; import { requestUrl } from "../requestUrl.ts"; @@ -1005,21 +1009,16 @@ const withToolAnalytics = const scheduledRunOf = (threadId: string, runId: RunId | null) => Option.isNone(threads) || runId === null ? Effect.succeed(false) - : threads.value - .getThreadRecords(ThreadId.make(threadId), ["runs", "messages"], { + : Effect.gen(function* () { + const id = ThreadId.make(threadId); + const { runs } = yield* threads.value.getThreadRecords(id, ["runs"], { runIds: [runId], - messageRoles: ["user"], - }) - .pipe( - Effect.map((records) => { - const run = records.runs.find((candidate) => candidate.id === runId); - return records.messages.some( - (message) => - message.id === run?.userMessageId && message.scheduledTaskId !== undefined, - ); - }), - Effect.orElseSucceed(() => false), - ); + }); + const run = runs.find((candidate) => candidate.id === runId); + if (run === undefined) return false; + const messages = yield* threads.value.getMessagesById(id, [run.userMessageId]); + return messages.get(run.userMessageId)?.scheduledTaskId !== undefined; + }).pipe(Effect.orElseSucceed(() => false)); const record = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced( @@ -1244,6 +1243,8 @@ export const layerOrchestratorToolkit = toolkitRegistration( export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer); +const layerMessageToolkit = toolkitRegistration(MessageToolkit, MessageHandlers.layer); + const layerWorktreeToolkitRegistration = toolkitRegistration( WorktreeToolkit, WorktreeHandlers.layer, @@ -1259,6 +1260,10 @@ const layerEnvironmentToolkit = toolkitRegistration( EnvironmentHandlers.layer, ).pipe(Layer.provide(ThreadCommandExecutor.layer)); +const layerThemeToolkit = toolkitRegistration(ThemeToolkit, ThemeHandlers.layer).pipe( + Layer.provide(ThreadCommandExecutor.layer), +); + export const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); export const layerAttachmentToolkit = toolkitRegistration( @@ -1324,11 +1329,13 @@ export const layerTools = Layer.mergeAll( layerAttachmentToolkit, layerProjectRegistration, layerEnvironmentToolkit, + layerThemeToolkit, layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, layerDeviceToolkit, layerHtmlToolkit, + layerMessageToolkit, ); const layerToolExposure = Layer.effect( diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index b99a7fbe45c3..2ff2125a5180 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -131,7 +131,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov number, url: `https://github.com/acme/web/pull/${number}`, source: "agent", - linkedAt: "2026-08-04T11:00:00.000Z", + linkedAt: `2026-08-04T11:0${number}:00.000Z`, snapshot: { state }, stack: null, ...(watched @@ -158,7 +158,11 @@ it("readThread preserves caller access errors and prefers activity-run status ov instanceId: parentInstanceId, model: "gpt-5.4", }), - pullRequests: [pullRequestLink(1, "merged", false), pullRequestLink(2, "open", true)], + pullRequests: [ + pullRequestLink(1, "merged", false), + pullRequestLink(2, "open", true), + ...[3, 4, 5, 6, 7, 8].map((number) => pullRequestLink(number, "open", false)), + ], }, runs: [ makeRun({ id: activeRunId, ordinal: 1, status: "running" }), @@ -244,8 +248,10 @@ it("readThread preserves caller access errors and prefers activity-run status ov { taskId: "task-subagent", kind: "subagent", childThreadId }, ]); // Every page read must not repeat each linked PR, and an open PR shows whether it is watched. + // A thread with many open PRs previews a few, watched first, then newest. const listed = result.thread.pullRequests ?? []; - expect(listed.map((link) => link.number)).not.toContain(1); + expect(listed.map((link) => link.number)).toEqual([2, 8, 7, 6, 5]); + expect(result.thread.openPullRequestCount).toBe(7); expect(listed.find((link) => link.number === 2)).toMatchObject({ watching: true, watchStartedAt: "2026-08-04T11:30:00.000Z", @@ -255,7 +261,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov afterPosition: 0, }); expect(laterPage.thread.pullRequests).toBeUndefined(); - expect(laterPage.thread.pullRequestCount).toBe(2); + expect(laterPage.thread.pullRequestCount).toBe(8); callerUnavailable = true; const denied = yield* service diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 03810fbefe59..a017590b1f30 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3975,6 +3975,7 @@ describe("OrchestratorMcpService thread list", () => { ], }, ]; + const shellRequests: Array> = []; const service = layerOrchestratorService.pipe( Layer.provide( Layer.mergeAll( @@ -3987,6 +3988,13 @@ describe("OrchestratorMcpService thread list", () => { (thread) => input.projectId === undefined || thread.projectId === input.projectId, ), ), + getShellSnapshot: (options) => { + const threadIds = options?.threadIds ?? shells.map((thread) => thread.id); + shellRequests.push(threadIds); + return Effect.succeed({ + threads: shells.filter((thread) => threadIds.includes(thread.id)), + } as never); + }, }), ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), @@ -4080,6 +4088,22 @@ describe("OrchestratorMcpService thread list", () => { }).pipe(Effect.provide(service)), ); + it.effect("builds shells only for the threads on the page", () => + Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + shellRequests.length = 0; + const listed = yield* mcp.listThreads(client, { + projectId: ProjectId.make("project:a"), + limit: 1, + }); + assert.deepStrictEqual( + [listed.threads.map((thread) => thread.threadId), listed.total, listed.nextCursor], + [["thread:a-snoozed"], 3, 1], + ); + assert.deepStrictEqual(shellRequests, [[ThreadId.make("thread:a-snoozed")]]); + }).pipe(Effect.provide(service)), + ); + it.effect("points a caller outside any thread at allProjects when it omits projectId", () => Effect.gen(function* () { const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f63f32d77caa..fc741bcac6ab 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -70,6 +70,7 @@ import { getProviderOptionCurrentValue, withLegacyUltracodeEffort } from "@t3too import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { threadPullRequestUpdatedAtMs } from "@t3tools/shared/threadPullRequests"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, @@ -100,7 +101,12 @@ import type { ProjectionRecordField, ProjectionRecordFilter, } from "../orchestration-v2/ProjectionStore.ts"; -import { delegatedTaskResultRun, runOutcome } from "../orchestration-v2/RestartBackgroundNote.ts"; +import { + delegatedTaskAwaitsWork, + delegatedTaskResultRun, + delegatedTaskRuns, + runOutcome, +} from "../orchestration-v2/RestartBackgroundNote.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -146,6 +152,7 @@ const DEFAULT_THREAD_LIST_LIMIT = 10; const DEFAULT_THREAD_READ_LIMIT = 10; const DEFAULT_THREAD_RUN_LIMIT = 10; const DEFAULT_THREAD_ITEM_MAX_CHARS = 500; +const THREAD_PULL_REQUEST_PREVIEW_LIMIT = 5; // Claude Code saves MCP results over ~25k tokens to a file instead of showing them. Bytes, not // chars: CJK text runs near one token per char, which a char budget lets through at 30k tokens. const THREAD_READ_RESULT_MAX_BYTES = 30_000; @@ -244,6 +251,7 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure { return failure("run_not_found", error.message); case "ThreadManagementThreadArchivedError": case "ThreadManagementNoSteerableRunError": + case "ThreadManagementWorktreeRestoreError": return failure("thread_not_sendable", error.message); case "ThreadManagementThreadNotInterruptibleError": return failure("thread_not_interruptible", error.message); @@ -823,12 +831,23 @@ function threadBackgroundWork( function threadPullRequests( links: OrchestrationV2ThreadShell["pullRequests"], includeLinks = true, -): Pick { +): Pick< + OrchestratorMcpThreadListItem, + "pullRequests" | "pullRequestCount" | "openPullRequestCount" +> { const visible = visibleThreadPullRequests(links ?? []); if (!includeLinks) return { pullRequestCount: visible.length }; + const open = visible.filter( + (link) => link.snapshot?.state !== "merged" && link.snapshot?.state !== "closed", + ); return { - pullRequests: visible - .filter((link) => link.snapshot?.state !== "merged" && link.snapshot?.state !== "closed") + pullRequests: open + .toSorted( + (left, right) => + Number(right.watch !== undefined) - Number(left.watch !== undefined) || + threadPullRequestUpdatedAtMs(right) - threadPullRequestUpdatedAtMs(left), + ) + .slice(0, THREAD_PULL_REQUEST_PREVIEW_LIMIT) .map((link) => ({ host: normalizeThreadPullRequestKey(link).host, repository: link.repository, @@ -840,6 +859,7 @@ function threadPullRequests( watchStartedAt: link.watch?.startedAt ?? null, })), pullRequestCount: visible.length, + openPullRequestCount: open.length, }; } @@ -1611,16 +1631,18 @@ const make = Effect.gen(function* () { const childRun = delegatedTaskRun(childControls, task); const terminalRun = latestTerminalResultRun(childControls, childRun); const progress = delegatedTaskProgress(childControls); + const followupRuns = + childRun?.delegatedTaskId === task.id ? delegatedTaskRuns(childControls, task.id) : undefined; const taskResultRun = - childRun?.delegatedTaskId === task.id - ? delegatedTaskResultRun(childControls.runs, task.id) - : progress.resultRun; + followupRuns === undefined ? progress.resultRun : delegatedTaskResultRun(followupRuns); const taskWorkState = - childRun?.delegatedTaskId === task.id - ? isTerminalTaskStatus(taskStatusForRun(taskResultRun)) - ? "result_available" - : "working" - : progress.state; + followupRuns === undefined + ? progress.state + : !isTerminalTaskStatus(taskStatusForRun(taskResultRun)) + ? "working" + : delegatedTaskAwaitsWork(followupRuns, childControls) + ? "waiting_for_children" + : "result_available"; const resultRunIds = [ ...new Set( [taskResultRun?.id, terminalRun?.id].filter((id): id is RunId => id !== undefined), @@ -3082,16 +3104,14 @@ const make = Effect.gen(function* () { if (!input.allProjects && input.projectId !== undefined) { yield* requireProject(input.projectId); } + const listFailure = (error: unknown) => + failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`); const projectThreads = yield* threadManagement .listProjectThreads({ projectId: projectId ?? undefined, includeSubagents: input.includeSubagents !== false, }) - .pipe( - Effect.mapError((error) => - failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), - ), - ); + .pipe(Effect.mapError(listFailure)); const nowMs = yield* Clock.currentTimeMillis; // An empty list is no filter: clients that fill every optional field send `[]`. const statuses = @@ -3099,29 +3119,58 @@ const make = Effect.gen(function* () { ? null : new Set(input.statuses); const titleContains = input.titleContains?.toLocaleLowerCase(); - const filtered = projectThreads - .filter((thread) => statuses === null || statuses.has(listedThreadStatus(thread))) + const candidates = projectThreads .filter( (thread) => input.settled === undefined || threadSettlement(thread).settled === input.settled, ) .filter( - (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed, + (thread) => + titleContains === undefined || thread.title.toLocaleLowerCase().includes(titleContains), ) + // Only a snooze that ends later can hold, so the rest skip building a shell. .filter( (thread) => - titleContains === undefined || thread.title.toLocaleLowerCase().includes(titleContains), + input.snoozed !== true || + (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs), ); + // Shells are the costly part, so build them only for the threads the page needs. + const shellsOf = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => + threads.length === 0 + ? Effect.succeed([]) + : threadManagement + .getShellSnapshot({ location: "active", threadIds: threads.map(({ id }) => id) }) + .pipe( + Effect.map((snapshot) => { + const shellsById = new Map(snapshot.threads.map((shell) => [shell.id, shell])); + return threads.flatMap(({ id }) => shellsById.get(id) ?? []); + }), + Effect.mapError(listFailure), + ); const cursor = input.cursor ?? 0; const limit = input.limit ?? DEFAULT_THREAD_LIST_LIMIT; - const page = filtered.slice(cursor, cursor + limit); - const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null; + let page: ReadonlyArray; + let total: number; + if (statuses === null && input.snoozed === undefined) { + total = candidates.length; + page = yield* shellsOf(candidates.slice(cursor, cursor + limit)); + } else { + // Status and snooze state are derived from the shell. + const filtered = (yield* shellsOf(candidates)) + .filter((thread) => statuses === null || statuses.has(listedThreadStatus(thread))) + .filter( + (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed, + ); + total = filtered.length; + page = filtered.slice(cursor, cursor + limit); + } + const nextCursor = cursor + limit < total ? cursor + limit : null; return { projectId, currentThreadId: parent?.thread.id ?? null, threads: page.map((shell) => listItemFromShell(scope, shell, nowMs)), nextCursor, - total: filtered.length, + total, } satisfies OrchestratorMcpThreadListResult; }), readThread: Effect.fn("OrchestratorMcpService.readThread")(function* (scope, input) { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index e800570b8ba5..dac5153acee6 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -18,8 +18,10 @@ import { import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { PreviewBrowserRootError } from "../preview/PreviewBrowserHost.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -950,6 +952,54 @@ it.effect("fails requests assigned to the stream that is replaced", () => }), ); +it.effect( + "keeps another thread's request alive when one request times out on the shared server browser", + () => + Effect.gen(function* () { + const broker = yield* makeBroker; + const clientId = SERVER_BROWSER_AUTOMATION_CLIENT_ID; + const delivered = yield* Queue.unbounded(); + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId }), { preferred: true }), + ); + yield* Stream.runForEach(requests, (request) => Queue.offer(delivered, request)).pipe( + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const otherScope = { + ...scope, + requestNamespace: "provider-session-2", + thread: { + threadId: ThreadId.make("thread-2"), + providerSessionId: "provider-session-2", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + }; + + const otherWait = yield* broker + .invoke({ scope: otherScope, operation: "waitFor", input: {}, timeoutMs: 40_000 }) + .pipe(Effect.result, Effect.forkScoped); + const otherRequest = yield* Queue.take(delivered); + // The browser never answers this one, as when it waits behind the thread's own running action. + const stalled = yield* broker + .invoke({ scope, operation: "evaluate", input: {} }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Queue.take(delivered); + + yield* TestClock.adjust("16 seconds"); + expect((yield* Fiber.join(stalled))._tag).toBe("PreviewAutomationTimeoutError"); + + yield* broker.respond({ + clientId, + connectionId: otherRequest.connectionId, + requestId: otherRequest.requestId, + ok: true, + result: "ready", + }); + expect(yield* Fiber.join(otherWait)).toEqual(Result.succeed("ready")); + }), +); + it.effect("accepts responses only from the host that received the request", () => Effect.gen(function* () { const broker = yield* makeBroker; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 85646d387160..073cecced051 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -662,7 +662,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { // The enclosing operation owns the span for this inline handler. onNone: Effect.fnUntraced(function* () { // Never replay possibly applied actions; slow metadata reads must not evict the host. - if (input.updateCurrentTab !== false) { + // The in-process server browser is shared by every thread and cannot hang its stream. + if ( + input.updateCurrentTab !== false && + connection.clientId !== SERVER_BROWSER_AUTOMATION_CLIENT_ID + ) { yield* disconnect(connection.clientId, connection.queue, true); } return yield* new PreviewAutomationTimeoutError(requestContext); diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 90cf3c7f8401..3dc7cc2bcc77 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -106,7 +106,6 @@ interface HarnessOptions { readonly threadAttachedOnRecheck?: boolean; readonly threadArchivedOnRecheck?: boolean; readonly threadReadFailsOnRecheck?: boolean; - readonly continuation?: "queued" | "fails" | "dies"; readonly projectMissing?: boolean; readonly projectReadFails?: boolean; readonly existingBranchWorktreePath?: string | null; @@ -179,22 +178,6 @@ const makeHarness = (options: HarnessOptions = {}) => { ? Effect.succeed(makeProjection(thread)) : Effect.fail(new OrchestratorProjectionError({ threadId: id })); }); - const sendToThread = vi.fn((_: unknown) => { - switch (options.continuation ?? "queued") { - case "fails": - return Effect.fail( - new ThreadManagementService.ThreadManagementThreadArchivedError({ - threadId, - }), - ); - case "dies": - return Effect.die(new Error("send defect")); - default: - return Effect.succeed({ - delivery: "queued", - } as ThreadManagementService.ThreadManagementSendResult); - } - }); const getById = vi.fn((id: ProjectId) => options.projectReadFails ? (Effect.fail("simulated project read failure") as never) @@ -298,7 +281,6 @@ const makeHarness = (options: HarnessOptions = {}) => { Layer.mock(ThreadManagementService.ThreadManagementService)({ dispatch, getThreadRecords, - sendToThread, } satisfies Partial), Layer.mock(ProjectService.ProjectService)({ getById, @@ -355,7 +337,6 @@ const makeHarness = (options: HarnessOptions = {}) => { scope, listRefs, dispatch, - sendToThread, fetchWorktreeBaseCommit, createWorktree, removeWorktree, @@ -398,30 +379,23 @@ const runStatus = (harness: ReturnType) => const decodeHandoffInput = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput); describe("t3_worktree_handoff", () => { - it.effect("reports a continuation failure without failing the handoff", () => { - const harness = makeHarness({ continuation: "fails" }); + // regression: #22971 a separate send let the detach settle the run before its follow-up existed + it.effect("commits the continuation with the thread binding", () => { + const harness = makeHarness(); return Effect.gen(function* () { const result = yield* runHandoff(harness, { - branch: "feature/continue-fails", + branch: "feature/continue", continuationPrompt: "Keep going.", }); - expect(result.continuation).toMatchObject({ status: "failed" }); - expect(result.worktreePath).toBe("/worktrees/project/feature/continue-fails"); - expect(harness.dispatch).toHaveBeenCalled(); - }); - }); - - it.effect("does not queue a continuation when the thread update fails", () => { - const harness = makeHarness({ dispatchFails: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - runHandoff(harness, { - branch: "feature/dispatch-fails-continue", - continuationPrompt: "Keep going.", + expect(result.continuation).toEqual({ status: "scheduled", delivery: "queued" }); + expect(harness.dispatch).toHaveBeenCalledTimes(1); + expect(harness.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "thread.metadata.update", + worktreePath: "/worktrees/project/feature/continue", + continuation: expect.objectContaining({ text: "Keep going." }), }), ); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.sendToThread).not.toHaveBeenCalled(); }); }); @@ -618,7 +592,7 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("queues the continuation even when interrupted during the binding dispatch", () => + it.effect("runs the setup script even when interrupted during the binding dispatch", () => Effect.gen(function* () { const gate = yield* Deferred.make(); const harness = makeHarness({ dispatchGate: Deferred.await(gate) }); @@ -637,7 +611,6 @@ describe("t3_worktree_handoff", () => { yield* Fiber.join(interruption); expect(harness.dispatch).toHaveBeenCalledTimes(1); - expect(harness.sendToThread).toHaveBeenCalledTimes(1); expect(harness.runForThread).toHaveBeenCalledTimes(1); }), ); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 0ee45bb7f8bb..5d73b864d356 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -147,7 +147,6 @@ const make = Effect.gen(function* () { ); return { commandId: CommandId.make(part("command", "worktree-handoff")), - continuationCommandId: CommandId.make(part("command", "worktree-continuation")), continuationMessageId: MessageId.make(part("message", "worktree-continuation")), }; }), @@ -293,7 +292,7 @@ const make = Effect.gen(function* () { `Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`, ); } - yield* threadManagement + return yield* threadManagement .dispatch({ type: "thread.metadata.update", commandId: ids.commandId, @@ -301,6 +300,15 @@ const make = Effect.gen(function* () { branch: worktree.worktree.refName, worktreePath, expectedWorktreePath: null, + // Binding detaches the session and ends the caller; a later send could lose the race. + ...(input.continuationPrompt === undefined + ? {} + : { + continuation: { + messageId: ids.continuationMessageId, + text: input.continuationPrompt, + }, + }), }) .pipe( Effect.catchCause((cause) => @@ -321,33 +329,18 @@ const make = Effect.gen(function* () { ), ); - // Queue continuation immediately after binding; detachment terminates the caller, so relaunch needs a durable message. - const queueContinuation: Effect.Effect = - Effect.suspend(() => - input.continuationPrompt === undefined - ? Effect.succeed({ status: "skipped" }) - : threadManagement - .sendToThread({ - projectId: projection.thread.projectId, - commandId: ids.continuationCommandId, - threadId: scope.thread.threadId, - messageId: ids.continuationMessageId, - text: input.continuationPrompt, - attachments: [], - mode: "queue", - createdBy: "agent", - creationSource: "mcp", - }) - .pipe( - Effect.map((sendResult): WorktreeMcpContinuationStatus => ({ - status: "scheduled", - delivery: sendResult.delivery, - })), - reportFailed("worktree handoff continuation failed to queue"), - ), - ); - - const continuation = yield* recheckAndBind.pipe(Effect.andThen(queueContinuation)); + const bound = yield* recheckAndBind; + const continuation: WorktreeMcpContinuationStatus = + input.continuationPrompt === undefined + ? { status: "skipped" } + : { + status: "scheduled", + delivery: bound.storedEvents.some( + ({ event }) => event.type === "run.created" && event.payload.status !== "queued", + ) + ? "started" + : "queued", + }; yield* vcsStatusBroadcaster .refreshStatus(worktreePath) diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 48d972c5be2f..416592a57fe7 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -1,4 +1,7 @@ -import type { ProjectionRecordField } from "../orchestration-v2/ProjectionStore.ts"; +import type { + ProjectionRecordField, + ProjectionRecordFilter, +} from "../orchestration-v2/ProjectionStore.ts"; import { CommandId, OrchestratorMcpFailure, @@ -182,7 +185,7 @@ const resolveThreadId = (context: Caller, threadId: ThreadId | undefined) => export const readThread = Effect.fn("mcp.readThread")(function* < K extends ProjectionRecordField = never, ->(threadId?: ThreadId, fields: ReadonlyArray = []) { +>(threadId?: ThreadId, fields: ReadonlyArray = [], filter?: ProjectionRecordFilter) { const context = yield* readCaller(); const targetId = yield* resolveThreadId(context, threadId); const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable)); @@ -192,6 +195,7 @@ export const readThread = Effect.fn("mcp.readThread")(function* < const projection = yield* context.threads .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { turnItemTypes: ["user_input_request"], + ...filter, }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 5e80cf41b36b..35a18f5f0b46 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -45,6 +45,7 @@ import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapter import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import type * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; import * as ProjectService from "../../project/ProjectService.ts"; @@ -467,6 +468,101 @@ it.effect("records only failed calls of tools that hand off no work", () => { ); }); +// A thread read whose runs or messages are bounded by nothing but the thread. +const historyReads = (reads: Array) => { + const record = ( + fields: ReadonlyArray, + filter: ProjectionStore.ProjectionRecordFilter | undefined, + ) => { + if ( + fields.includes("runs") && + filter?.runIds === undefined && + filter?.runStatuses === undefined + ) + reads.push("every run"); + if ( + fields.includes("messages") && + filter?.messageIds === undefined && + filter?.messageRunIds === undefined + ) + reads.push("every message"); + }; + const projection = McpToolAccessTestkit.idleThreadProjection( + McpToolAccessTestkit.liveThreadShell(threadId), + ); + return Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: (_target, fields, filter) => + Effect.sync(() => record(fields, filter)).pipe(Effect.as(projection as never)), + getThreadRecords: (_id, fields, filter) => + Effect.sync(() => record(fields, filter)).pipe(Effect.as(projection as never)), + getMessagesById: () => Effect.succeed(new Map()), + dispatch: () => Effect.succeed({ sequence: 1 } as never), + }); +}; + +it.effect("t3_queue list and read load only the queued runs and their messages", () => { + const reads: Array = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = (args: Record) => + server + .callTool({ name: "t3_queue", arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + yield* call({ action: "list" }); + yield* call({ action: "read", queuedRunId: "run:queued" }); + expect(reads).toEqual([]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide(NodeCrypto.layer), + Layer.provide(historyReads(reads)), + ), + ), + ); +}); + +it.effect("handoff analytics read only the caller's active run and its message", () => { + const reads: Array = []; + const recorded = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + yield* server + .callTool({ + name: "t3_queue", + arguments: { action: "edit", queuedRunId: "run:queued", text: "Edited" }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + yield* Deferred.await(recorded); + expect(reads).toEqual([]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide(NodeCrypto.layer), + Layer.provide(historyReads(reads)), + Layer.provide( + Layer.succeed( + AnalyticsService.AnalyticsService, + AnalyticsService.AnalyticsService.of({ + record: () => Deferred.succeed(recorded, undefined).pipe(Effect.asVoid), + flush: Effect.void, + recordOwners: () => Effect.void, + }), + ), + ), + ), + ), + ); +}); + it.effect("rejects an unknown organize target instead of archiving the calling thread", () => { const dispatched: Array = []; return Effect.gen(function* () { @@ -1232,6 +1328,7 @@ it.effect( Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ listProjectThreads: () => Effect.succeed([unsettled, settled]), + getShellSnapshot: () => Effect.succeed({ threads: [unsettled, settled] } as never), }), ), Layer.provide(ServerSettings.layerTest()), diff --git a/apps/server/src/mcp/toolkits/message/handlers.ts b/apps/server/src/mcp/toolkits/message/handlers.ts new file mode 100644 index 000000000000..defe3609f08c --- /dev/null +++ b/apps/server/src/mcp/toolkits/message/handlers.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; + +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { MessageToolkit } from "./tools.ts"; + +const handlers = { + send_to_user: McpToolAccess.actsAsCaller((input) => + Effect.succeed({ + sentMessage: input.message, + message: "Shown to the user. Don't repeat it.", + }), + ), +} satisfies McpToolAccess.Handlers; + +export const layer = McpToolAccess.toLayer(MessageToolkit, handlers); diff --git a/apps/server/src/mcp/toolkits/message/tools.ts b/apps/server/src/mcp/toolkits/message/tools.ts new file mode 100644 index 000000000000..848cfa46976f --- /dev/null +++ b/apps/server/src/mcp/toolkits/message/tools.ts @@ -0,0 +1,39 @@ +import { OrchestratorMcpFailure } from "@t3tools/contracts"; +import { SEND_TO_USER_MAX_LENGTH, SEND_TO_USER_TOOL_NAME } from "@t3tools/shared/toolOutput"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; + +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +// Claude 5.1+ returns text written before a tool call only as a short summary; tool input arrives intact. +// It affects only the caller's thread, so read-only sandboxes and plan mode may use it. +const SendToUserTool = Tool.make(SEND_TO_USER_TOOL_NAME, { + description: + "Show the user a Markdown message in this thread, exactly as written, before your turn ends. Use it for content the user must read in full mid-turn, such as a draft, a code block, or what a question you are about to ask refers to. Don't repeat it in your reply.", + parameters: Schema.Struct({ + message: Schema.String.check( + Schema.isMinLength(1), + Schema.isMaxLength(SEND_TO_USER_MAX_LENGTH), + ).annotate({ description: "Markdown, shown verbatim." }), + }), + success: Schema.Struct({ + sentMessage: Schema.String, + message: Schema.String, + }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ], +}) + .annotate(Tool.Title, "Send a message to the user") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false) + // Claude Code defers MCP tools behind ToolSearch; Anthropic says to declare this from the first request. + .annotate(Tool.Meta, { "anthropic/alwaysLoad": true }); + +export const MessageToolkit = Toolkit.make(SendToUserTool); diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index b2ba460d0c8b..6e649222854b 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -788,6 +788,7 @@ it.effect("an agent's clone is not cut off while git is still transferring", () ), Layer.provide( Layer.mock(GitVcsDriver.GitVcsDriver)({ + invalidateRefs: () => Effect.void, // Like the real driver, the clone fails once it outlives the deadline it was given. execute: (input) => Deferred.succeed(started, undefined).pipe( diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 2ad7161cb630..ddbfc575030f 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -65,7 +65,7 @@ function worktreeCheckHint( } return `Pass a branch, tag, or commit that exists there${ strategy.startFromOrigin === true - ? "; the project has no usable remote to fetch it from." + ? "; no remote of the project has it either." : ", or set startFromOrigin:true if it exists only on a remote." }`; } diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index d6c746440a60..e797d6bcf5c2 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -537,8 +537,8 @@ describe("pull request toolkit handlers", () => { ]), }); const result = yield* harness.call("list_thread_pull_requests", {}); - expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]); - expect(result.pullRequests[0]).toEqual({ + expect(result.pullRequests.map((entry) => entry.number)).toEqual([1, 2, 3, 10]); + expect(result.pullRequests[2]).toEqual({ host: "github.com", repository: "t3tools/t3code", number: 3, @@ -587,8 +587,8 @@ describe("listThreadPullRequests", () => { ], }); expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([ - [2, { kind: "native", position: 2, size: 2 }], [1, { kind: "native", position: 1, size: 2 }], + [2, { kind: "native", position: 2, size: 2 }], ]); expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]); }); @@ -608,6 +608,58 @@ describe("listThreadPullRequests", () => { ]); expect(result.chains).toEqual([{ kind: "derived", numbers: [1, 2, 3] }]); }); + + it("pages a large list once through, with each page's stacks whole", () => { + const thread = { + pullRequests: [ + makeLink(1, { headBranch: "feat-a" }), + ...Array.from({ length: 118 }, (_, index) => makeLink(index + 2)), + makeLink(120, { headBranch: "feat-z", baseBranch: "feat-a" }), + ], + }; + const pages = [listThreadPullRequests(thread)]; + for (let next = pages[0]!.nextCursor; next !== null; next = pages.at(-1)!.nextCursor) { + pages.push(listThreadPullRequests(thread, next)); + } + expect(pages.map((page) => page.pullRequests.length)).toEqual([50, 50, 20]); + expect(pages.flatMap((page) => page.pullRequests.map((entry) => entry.number))).toEqual( + Array.from({ length: 120 }, (_, index) => index + 1), + ); + const stack = { kind: "derived", numbers: [1, 120] }; + expect(pages.map((page) => page.chains.length)).toEqual([50, 50, 20]); + expect(pages.map((page) => page.chains.filter((chain) => chain.numbers.length > 1))).toEqual([ + [stack], + [], + [stack], + ]); + + // Unlinking the first page must not shift the cursor past PRs not yet seen. + const remaining = { pullRequests: thread.pullRequests.slice(50) }; + expect( + listThreadPullRequests(remaining, pages[0]!.nextCursor!).pullRequests.map( + (entry) => entry.number, + ), + ).toEqual(Array.from({ length: 50 }, (_, index) => index + 51)); + }); + + it("filters by state before paging, counting a PR with no known state as open", () => { + const merged = (number: number) => { + const link = makeLink(number, { headBranch: `feat-${number}` }); + return { ...link, snapshot: { ...link.snapshot!, state: "merged" as const } }; + }; + const thread = { + pullRequests: [ + ...Array.from({ length: 50 }, (_, index) => merged(index + 1)), + ...Array.from({ length: 10 }, (_, index) => makeLink(index + 51)), + ], + }; + const open = listThreadPullRequests(thread, undefined, ["open"]); + expect(open.pullRequests.map((entry) => entry.number)).toEqual( + Array.from({ length: 10 }, (_, index) => index + 51), + ); + expect(open.nextCursor).toBeNull(); + expect(listThreadPullRequests(thread, undefined, ["merged"]).nextCursor).toBeNull(); + }); }); describe("pull request guide tools", () => { diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 8983b8e62652..84e65ce877f6 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -1,6 +1,7 @@ import { CommandId, type OrchestrationProjectShell, + type PullRequestState, type OrchestrationV2ThreadShell, type ThreadId, type ThreadPullRequestLink, @@ -72,18 +73,39 @@ function entryOf( }; } +const PULL_REQUEST_LIST_PAGE_SIZE = 50; + +// A total order that survives unlinking: the cursor names a position, not an index, so removing +// PRs already returned cannot skip later ones. PRs linked in one call share `linkedAt`. +function pullRequestListPosition(link: ThreadPullRequestLink): string { + return [link.linkedAt, link.host, link.repository, String(link.number).padStart(10, "0")].join( + " ", + ); +} + export function listThreadPullRequests( thread: Pick, + cursor?: string, + states?: ReadonlyArray, ): ListThreadPullRequestsResult { const chains = resolveThreadPullRequestChains(thread.pullRequests ?? []); + const after = visibleThreadPullRequests(thread.pullRequests ?? []) + .filter((link) => states === undefined || states.includes(link.snapshot?.state ?? "open")) + .map((link) => ({ link, position: pullRequestListPosition(link) })) + .filter(({ position }) => cursor === undefined || position > cursor) + .toSorted((left, right) => (left.position < right.position ? -1 : 1)); + const page = after.slice(0, PULL_REQUEST_LIST_PAGE_SIZE); + const pageKeys = new Set(page.map(({ link }) => threadPullRequestKeyOf(link))); return { - pullRequests: visibleThreadPullRequests(thread.pullRequests ?? []).map((link) => - entryOf(link, chains), - ), - chains: chains.map((chain) => ({ - kind: chain.kind, - numbers: chain.layers.map((layer) => layer.number), - })), + pullRequests: page.map(({ link }) => entryOf(link, chains)), + // Stacks with a pull request on this page, whole. + chains: chains + .filter((chain) => chain.layers.some((layer) => pageKeys.has(threadPullRequestKeyOf(layer)))) + .map((chain) => ({ + kind: chain.kind, + numbers: chain.layers.map((layer) => layer.number), + })), + nextCursor: after.length > page.length ? page.at(-1)!.position : null, }; } @@ -407,7 +429,7 @@ const make = Effect.gen(function* () { ), list_thread_pull_requests: McpToolAccess.reads((input) => requireThread(PullRequestListFailedError, input.threadId).pipe( - Effect.map(listThreadPullRequests), + Effect.map((thread) => listThreadPullRequests(thread, input.cursor, input.states)), ), ), watch_pull_request: writesThread((input) => setWatching(input, true)), diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index fe8959b2d22d..376817db1007 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -236,6 +236,8 @@ export const ListThreadPullRequestsResult = Schema.Struct({ numbers: Schema.Array(Schema.Int), }), ), + /** Pass as `cursor` for the next page; null on the last page. */ + nextCursor: Schema.NullOr(Schema.String), }); export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type; @@ -255,7 +257,7 @@ const LinkPullRequestTool = Tool.make("link_pull_request", { const LinkPullRequestsTool = Tool.make("link_pull_requests", { description: - "Link several PRs to a thread in one call. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.", + "Link several PRs to a thread. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), @@ -287,7 +289,7 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", { description: - "Unlink every PR on a thread whose last known state (as `list_thread_pull_requests` shows) is in `states`, in one call. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.", + "Unlink a thread's PRs whose last known state is in `states`. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), @@ -305,10 +307,12 @@ const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", { const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { description: - "List PRs linked to a thread, with last known state and stack order (bottom to top). Call only when you need that list. Never as an end-of-work check.", + "List a thread's PRs, with last known state and stack order (bottom to top). Not an end-of-work check.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), + states: Schema.optional(Schema.Array(PullRequestState)), + cursor: Schema.optional(Schema.String), }), success: ListThreadPullRequestsResult, failure: PullRequestToolError, diff --git a/apps/server/src/mcp/toolkits/theme/handlers.test.ts b/apps/server/src/mcp/toolkits/theme/handlers.test.ts new file mode 100644 index 000000000000..4da9c8a15f42 --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/handlers.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + type EnvironmentThemeFile, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { + EnvironmentThemeRefusedError, + EnvironmentThemeService, +} from "../../../environmentTheme.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; +import * as ThemeHandlers from "./handlers.ts"; +import { ThemeToolkit } from "./tools.ts"; + +const environmentId = EnvironmentId.make("environment:theme"); +const threadId = ThreadId.make("thread:theme"); +const AGENT_THEME = { + name: "Agent Look", + appearance: "dark", + canvas: "#101010", + accent: "#7aa2f7", + css: "[data-chat-header] { border-bottom: 1px solid red; }", +} satisfies EnvironmentThemeFile; + +it.effect("applies the theme through the service and reports a refusal as an invalid request", () => + Effect.gen(function* () { + const layerDependencies = Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId, + requestNamespace: "provider:theme", + thread: { + threadId, + providerSessionId: "provider:theme", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration" as const]), + issuedAt: 0, + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(liveThreadShell(threadId)), + }), + Layer.mock(EnvironmentThemeService)({ + apply: (input) => + "themeId" in input && input.themeId === "missing" + ? Effect.fail( + new EnvironmentThemeRefusedError({ + themeId: input.themeId, + problem: "is not a built-in or published theme.", + }), + ) + : Effect.void, + }), + ); + yield* Effect.gen(function* () { + const toolkit = yield* ThemeToolkit; + const call = (params: Parameters>[1]) => + toolkit.handle("t3_theme_set", params).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((parts) => parts.at(-1)?.result), + ); + + expect(yield* call({ themeId: "agent-look", theme: AGENT_THEME })).toEqual({ + themeId: "agent-look", + published: true, + }); + expect(yield* call({ themeId: "grove" })).toEqual({ themeId: "grove", published: false }); + expect(yield* call({ appearance: "dark" })).toEqual({ + appearance: "dark", + published: false, + }); + expect(yield* call({ theme: AGENT_THEME })).toMatchObject({ code: "invalid_request" }); + expect(yield* call({})).toMatchObject({ code: "invalid_request" }); + expect(yield* call({ themeId: "missing" })).toMatchObject({ + code: "invalid_request", + message: 'Theme "missing" is not a built-in or published theme.', + }); + }).pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ThemeHandlers.layer).pipe( + Layer.provideMerge(layerDependencies), + ), + ), + ); + }), +); diff --git a/apps/server/src/mcp/toolkits/theme/handlers.ts b/apps/server/src/mcp/toolkits/theme/handlers.ts new file mode 100644 index 000000000000..ad469095b0e0 --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/handlers.ts @@ -0,0 +1,79 @@ +import { OrchestratorMcpFailure } from "@t3tools/contracts"; +import { THEME_GUIDE } from "@t3tools/shared/themeCss"; +import { BUILT_IN_THEME_IDS } from "@t3tools/shared/themePalettes"; +import * as Effect from "effect/Effect"; +import * as Struct from "effect/Struct"; +import * as EnvironmentTheme from "../../../environmentTheme.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { unavailable } from "../../threadAccess.ts"; +import { ThemeToolkit } from "./tools.ts"; + +export const layer = McpToolAccess.toLayer(ThemeToolkit, { + t3_theme_read: McpToolAccess.reads(({ themeId }) => + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const { + current, + themes: published, + theme, + } = yield* themes.read(themeId).pipe( + Effect.catchTags({ + EnvironmentThemeRefusedError: (error) => + Effect.fail( + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }), + ), + ServerSettingsError: () => Effect.fail(unavailable()), + }), + ); + return { + current, + builtIn: BUILT_IN_THEME_IDS, + published: published.map(({ id, name, css }) => ({ id, name, hasCss: Boolean(css) })), + ...(theme === undefined ? {} : { theme: Struct.omit(theme, ["id"]) }), + guide: THEME_GUIDE, + }; + }), + ), + t3_theme_set: McpToolAccess.writesEnvironment(({ themeId, theme, appearance }, check) => + Effect.gen(function* () { + const input = + themeId !== undefined + ? { themeId, theme, appearance } + : theme === undefined && appearance !== undefined + ? { appearance } + : undefined; + if (input === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Give `themeId`, `appearance`, or both. `theme` needs a `themeId`.", + }); + } + const scope = yield* McpInvocationContext.McpInvocationContext; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const set = Effect.gen(function* () { + // The turn may have ended, or the thread's modes changed, while this waited for the lock. + yield* check; + yield* themes.apply(input).pipe( + Effect.catchTags({ + EnvironmentThemeRefusedError: (error) => + Effect.fail( + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }), + ), + EnvironmentThemeNotSelectedError: () => Effect.fail(unavailable()), + }), + ); + return { + ...(themeId === undefined ? {} : { themeId }), + ...(appearance === undefined ? {} : { appearance }), + published: theme !== undefined, + }; + }); + return yield* scope.thread === undefined + ? set + : executor.withLock(scope.thread.threadId, set); + }), + ), +}); diff --git a/apps/server/src/mcp/toolkits/theme/tools.ts b/apps/server/src/mcp/toolkits/theme/tools.ts new file mode 100644 index 000000000000..291a34f6ecb6 --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/tools.ts @@ -0,0 +1,69 @@ +import { + DefaultThemePreference, + EnvironmentThemeFile, + EnvironmentThemeId, + OrchestratorMcpFailure, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; +import * as EnvironmentTheme from "../../../environmentTheme.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; + +const Appearance = Schema.Literals(["light", "dark", "system"]); + +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + EnvironmentTheme.EnvironmentThemeService, + ThreadCommandExecutor.ThreadCommandExecutor, + ], +}; + +const ThemeReadTool = Tool.make("t3_theme_read", { + ...shared, + description: + "Read T3 Code's own themes and the guide to restyle it: color roles and CSS selectors. Call before `t3_theme_set`.", + parameters: Schema.Struct({ + themeId: Schema.optionalKey( + EnvironmentThemeId.annotate({ description: "Also return this published theme's file." }), + ), + }), + success: Schema.Struct({ + current: DefaultThemePreference, + builtIn: Schema.Array(Schema.String), + published: Schema.Array( + Schema.Struct({ + id: EnvironmentThemeId, + name: Schema.String, + hasCss: Schema.Boolean, + }), + ), + theme: Schema.optionalKey(EnvironmentThemeFile), + guide: Schema.String, + }), +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const ThemeSetTool = Tool.make("t3_theme_set", { + ...shared, + description: + "Restyle T3 Code itself, only when the user asks. With `theme`, publish it as `themeId` (the same id updates it) and switch this machine's web and desktop clients to it. Without `theme`, switch to a built-in or published `themeId`. `appearance` switches them to light, dark, or system mode, with or without a theme. Live full-access or default caller thread only.", + parameters: Schema.Struct({ + themeId: Schema.optionalKey(EnvironmentThemeId), + theme: Schema.optionalKey(EnvironmentThemeFile), + appearance: Schema.optionalKey(Appearance), + }), + success: Schema.Struct({ + themeId: Schema.optionalKey(EnvironmentThemeId), + appearance: Schema.optionalKey(Appearance), + published: Schema.Boolean, + }), +}).annotate(Tool.Destructive, true); + +export const ThemeToolkit = Toolkit.make(ThemeReadTool, ThemeSetTool); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 62023dd986b5..88bb91bf62b7 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -269,7 +269,16 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { (input) => input.action === "list" || input.action === "read", McpToolAccess.reads((input) => Effect.gen(function* () { - const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); + const { threads, projection: queued } = yield* readThread(input.threadId, ["runs"], { + runStatuses: ["queued"], + }); + const messages = yield* threads + .getMessagesById( + queued.thread.id, + queued.runs.map((run) => run.userMessageId), + ) + .pipe(Effect.mapError(unavailable)); + const projection = { runs: queued.runs, messages: [...messages.values()] }; if (input.action === "list") { const runs = queuedRunsInDeliveryOrder(projection); const cursor = input.cursor ?? 0; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 48d9dd82a6ef..70f45bc8a46c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1768,6 +1768,95 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + // Copilot holds session/prompt while an attached async shell runs (github/copilot-cli#5293); + // its documented foreground-done signal is the opt-in assistant.idle session event. + it.effect("settles Copilot's reply on assistant.idle while an async shell holds the prompt", () => + Effect.gen(function* () { + const sessionId = "copilot-session"; + const sessionEvent = (type: string) => ({ + type: "emit_inbound", + frame: { + kind: "notification", + method: "github.com/copilot/sessionEvent", + params: { sessionId, type, data: {} }, + }, + }); + const update = (sessionUpdate: Record) => + claudeFrames.update(sessionId, sessionUpdate); + const session = yield* openClaudeReplaySession( + [ + { + type: "expect_outbound", + frame: { kind: "request", method: "initialize", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "initialize", + result: { + protocolVersion: 1, + agentInfo: { name: "Copilot", version: "1.0.110" }, + agentCapabilities: { loadSession: true }, + authMethods: [], + }, + }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/new", params: "" }, + }, + { + type: "emit_inbound", + frame: { kind: "response", method: "session/new", result: { sessionId } }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + // A previous prompt's idle can land before this prompt's turn starts. + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after stale idle" }), + sessionEvent("assistant.turn_start"), + update({ + sessionUpdate: "tool_call", + toolCallId: "shell-1", + title: "sleep 120", + kind: "execute", + status: "completed", + rawInput: { command: "sleep 120", mode: "async", detach: false }, + }), + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } }), + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after foreground idle" }), + // The shell never exits, so the first session/prompt is never answered. + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + sessionEvent("assistant.turn_start"), + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "two" } }), + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after second idle" }), + ], + undefined, + false, + "github-copilot-cli", + ); + yield* session.runtime.startTurn(session.turnInput(1)); + const stale = yield* session.throughMarker("after stale idle"); + assert.isFalse(stale.some((event) => event.type === "turn.terminal")); + const foreground = yield* session.throughMarker("after foreground idle"); + const terminal = foreground.find((event) => event.type === "turn.terminal"); + assert.isDefined(terminal, "the run settles once Copilot's foreground work is idle"); + assert.equal(terminal?.type === "turn.terminal" ? terminal.status : null, "completed"); + yield* session.runtime.startTurn(session.turnInput(2)); + const second = yield* session.throughMarker("after second idle"); + const secondTerminal = second.find((event) => event.type === "turn.terminal"); + assert.equal(secondTerminal?.type === "turn.terminal" ? secondTerminal.runOrdinal : null, 2); + }).pipe(Effect.provide(layerTest)), + ); + it.effect.each([ { kind: "approval", stopReason: "end_turn", status: "completed" }, { kind: "question", stopReason: "error", status: "failed" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e529b68ee77e..d15f27177e14 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -90,7 +90,9 @@ import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH } from "@t3tools/prov import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; +import { MessageToolkit } from "../../mcp/toolkits/message/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; +import { ThemeToolkit } from "../../mcp/toolkits/theme/tools.ts"; import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; @@ -109,6 +111,7 @@ import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderInstallationGate from "../../provider/ProviderInstallationGate.ts"; import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts"; +import { withInlineContext } from "../ContextHandoffDelivery.ts"; import { provideDeterministicTestRuntime } from "../testkit/DeterministicRuntime.ts"; import { runOrchestratorV2Scenario } from "../testkit/OrchestratorScenario.ts"; import { layerProviderReplay } from "../testkit/ProviderReplayHarness.ts"; @@ -660,8 +663,10 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...Object.values(WorktreeToolkit.tools), ...Object.values(ProjectToolkit.tools), ...Object.values(EnvironmentToolkit.tools), + ...Object.values(ThemeToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), ...Object.values(HtmlToolkit.tools), + ...Object.values(MessageToolkit.tools), ] .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) .map((tool) => `mcp__t3-code__${tool.name}`) @@ -5525,6 +5530,55 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #34393 a provider switch replayed a $skill from the handoff history + it.effect("dispatches only skills from the user's text, not from inline handoff history", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skill-handoff-" }); + const directory = path.join(homePath, "skills", "review"); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString( + path.join(directory, "SKILL.md"), + "---\nname: review\n---\nFollow the review instructions.\n", + ); + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath }, + }); + const history = "Context handoff:\n[Historical user; user_message]\n$review the diff"; + const text = withInlineContext(history, "continue"); + yield* harness.runtime.startTurn({ + ...makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("skill-handoff"), + text, + attachments: [], + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: homePath }, + }), + inlineContextLength: text.length - "continue".length, + }); + + const content = harness.offeredMessages[0]?.message.content; + const blocks = + typeof content === "string" + ? [content] + : (content ?? []).map((block) => (block.type === "text" ? block.text : "")); + assert.isFalse(blocks.some((text) => text.startsWith("/review"))); + assert.include(blocks.join("\n"), "$review the diff"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #15475 MCP title arriving after the tool starts must survive to completion it.effect.each([ { isError: false, title: "Check weather" }, @@ -9099,6 +9153,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { environment: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "300000" }, threshold: 267_000, }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: '{"autoCompactWindow":400000,"autoCompactEnabled":false}', + threshold: undefined, + }, + { + autoCompactWindow: "400000", + model: "claude-opus-5-5", + environment: { DISABLE_AUTO_COMPACT: "1" }, + threshold: undefined, + }, ])( "ignores stale snapshots after compaction and reports threshold $autoCompactWindow for $model", ({ @@ -11633,6 +11699,201 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // Claude 2.1.323 wakes a session per Monitor event with only init, assistant and a + // task-notification result; just the Monitor's end sends a task_notification frame. + it.effect.each([ + { monitorWake: "ends with its own result", laterWake: "ends with its own result" }, + { monitorWake: "ends with its own result", laterWake: "is absorbed by a user turn" }, + { monitorWake: "is absorbed by a user turn", laterWake: "is absorbed by a user turn" }, + ] as const)( + "tags a Claude Monitor event wake as a monitor update (it $monitorWake, later wake $laterWake)", + ({ monitorWake, laterWake }) => + Effect.gen(function* () { + const absorbed = laterWake === "is absorbed by a user turn"; + const monitorWakeAbsorbed = monitorWake === "is absorbed by a user turn"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const taskId = "bi5kfx7xp"; + const toolUseId = "toolu_monitor_events"; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-monitor-event"), + text: "Watch the mutation run and report each survivor.", + attachments: [], + }), + ); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_monitor_events", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Monitor", + input: { description: "line monitor", command: "./mutate.sh" }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000901", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + description: "line monitor", + is_backgrounded: true, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000902", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `Monitor started (task ${taskId}). You will be notified on each event.`, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000903", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { taskId, persistent: false }, + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000904", result: "STARTED" }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait( + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000905", + text: "Covered. Waiting for the rest.", + }), + ); + yield* harness.awaitContinuation(1); + + const notification = harness.continuationRequests[0]?.notification; + assert.deepEqual( + { source: notification?.source, outcome: notification?.outcome }, + { source: { kind: "monitor" }, outcome: "updated" }, + ); + + let settled = 1; + // Claude folds a prompt into the wake turn it already opened, so one result ends both. + const absorbWithUserTurn = Effect.fnUntraced(function* (result: SDKMessage) { + settled += 1; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-claude-monitor-event-user-${settled}`), + text: "Any survivors yet?", + attachments: [], + providerTurnOrdinal: settled, + }), + ); + yield* harness.until(() => harness.offeredMessages.length === settled); + yield* Queue.offer(harness.sdkMessages, result); + yield* harness.awaitTerminal(settled); + }); + const monitorWakeResult = makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000906", + result: "Covered. Waiting for the rest.", + ...(monitorWakeAbsorbed ? {} : { origin: { kind: "task-notification" } as const }), + }); + if (monitorWakeAbsorbed) yield* absorbWithUserTurn(monitorWakeResult); + else yield* harness.offerAndWait(monitorWakeResult); + + // A wake buffered behind the queued Monitor continuation must not run under its tag. + for (const frame of [ + claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000907", + session_id: WAKE_NATIVE_SESSION, + }), + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000908", + text: "Scheduled answer.", + }), + ]) { + yield* harness.offerAndWait(frame); + } + const laterResult = makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000909", + result: absorbed ? "No survivors yet." : "Scheduled answer.", + }); + if (absorbed) yield* absorbWithUserTurn(laterResult); + else yield* harness.offerAndWait(laterResult); + const assistantTexts = () => + harness.events.flatMap((event) => + event.type === "message.updated" && event.message.role === "assistant" + ? [event.message.text] + : [], + ); + const startContinuation = (attempt: string, providerTurnOrdinal: number) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(attempt), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* startContinuation("attempt-claude-monitor-event-2", settled + 1); + yield* harness.awaitTerminal(settled + 1); + assert.notInclude(assistantTexts(), "Scheduled answer."); + yield* harness.awaitContinuation(2); + // With no result of its own in the buffer, the later wake reads as another Monitor event. + assert.equal( + harness.continuationRequests[1]?.notification?.source.kind, + absorbed ? "monitor" : undefined, + ); + + yield* startContinuation("attempt-claude-monitor-event-3", settled + 2); + yield* harness.awaitTerminal(settled + 2); + assert.equal(harness.terminalEvents()[settled + 1]?.status, "completed"); + assert.include(assistantTexts(), "Scheduled answer."); + assert.lengthOf(harness.continuationRequests, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + const scheduleWakeupFrames = (input: { readonly toolUseId: string; readonly uuids: readonly [string, string]; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 21608bdd02be..cbb960ae4e1c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -274,6 +274,11 @@ function claudePromptCacheTtlMs( // getContextUsage reports a 150,000 window as a 117,000 threshold, for every catalog model. const CLAUDE_AUTO_COMPACT_RESERVE_TOKENS = 33_000; +// Claude Code's isEnvTruthy. +function isClaudeEnvTruthy(value: string | undefined): boolean { + return value !== undefined && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + function claudeAutoCompactThreshold(autoCompactWindow: string, maxTokens: number | null) { const windowTokens = Number(autoCompactWindow); return windowTokens > 0 && (maxTokens === null || windowTokens < maxTokens) @@ -378,10 +383,13 @@ const CLAUDE_ROOT_REFUSAL = "--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons"; // fd 4 gates the launch until the server has recorded the process it is about to become. +// The shell enters the cwd itself: as a native cwd, a mount that hangs chdir freezes the server. const CLAUDE_GROUP_WATCH_SCRIPT = [ "read -r _ <&4 || exit 1", "exec 4<&-", "{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } /dev/null 2>&1 &", + 'cd -P -- "$1" || exit 125', + "shift", 'exec "$@" 3<&-', ].join("\n"); @@ -398,11 +406,19 @@ export function spawnClaudeProcessGroup( onStderrClose: () => void = () => {}, register?: (pid: number) => () => void, ): ClaudeSpawnedProcess & Pick { + // Relative to the server's cwd, as a native cwd is; the `./` keeps CDPATH out of it. + const cwd = options.cwd ?? "."; const child = NodeChildProcess.spawn( "/bin/sh", - ["-c", CLAUDE_GROUP_WATCH_SCRIPT, "t3-claude", options.command, ...options.args], + [ + "-c", + CLAUDE_GROUP_WATCH_SCRIPT, + "t3-claude", + cwd.startsWith("/") ? cwd : `./${cwd}`, + options.command, + ...options.args, + ], { - cwd: options.cwd, env: options.env, signal: options.signal, stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], @@ -1958,6 +1974,14 @@ const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = ` To wait for a background command or Monitor you started (tests, a build), call \`t3_background_wait\` with its task ID before you end your turn. Do not mark work you leave running, or PRs. T3 MCP tools are deferred: load one with ToolSearch before you call it. `; +// Claude 5.1+ returns text written before a tool call as a progress update, which the API +// returns only as a summary. +const CLAUDE_SEND_TO_USER_INSTRUCTIONS = ` +### Text the user must read mid-turn + +Text you write before a tool call may reach the user only as a one-line summary. When the user must read something in full before your turn ends, such as a draft, a code block, or what a question you're about to ask refers to, send it with \`send_to_user\`. Put everything else in your final reply. +`; + // Native subagents start from their task, not this session's system prompt. const subagentDeviceInstructions: HookCallback = () => Promise.resolve({ @@ -2157,6 +2181,7 @@ export function makeClaudeQueryOptions(input: { ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS + + CLAUDE_SEND_TO_USER_INSTRUCTIONS + (deviceInstructions ? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n` : "")), @@ -2282,8 +2307,10 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_preview_list", "mcp__t3-code__t3_environment_read", "mcp__t3-code__t3_environment_links", + "mcp__t3-code__t3_theme_read", "mcp__t3-code__html_preview", "mcp__t3-code__html_render", + "mcp__t3-code__send_to_user", ]; // T3 waits can block for an hour; exceed Claude's default 60 s timeout. @@ -2663,12 +2690,15 @@ export function makeClaudeUserMessage(input: { readonly text: string; readonly priority?: SDKUserMessage["priority"]; readonly skills?: ReadonlyMap; + readonly skillMentionsFrom?: number; readonly uuid?: SDKUserMessage["uuid"]; }): SDKUserMessage { // Claude expands a skill only when `/name` starts the last text block. // Keep the whole request before that block; see ClaudeSkillDispatch. const dispatch = - input.skills === undefined ? undefined : planClaudeSkillDispatch(input.text, input.skills); + input.skills === undefined + ? undefined + : planClaudeSkillDispatch(input.text, input.skills, input.skillMentionsFrom); // Claude runs `/goal` only when it opens the prompt, so a goal stays one block. const isGoalCommand = parseGoalCommand({ text: input.text }) !== null; return { @@ -2833,11 +2863,15 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: readonly fileSystem: FileSystem.FileSystem; readonly pdfReservation: Effect.Success>; readonly skills?: ReadonlyMap; + readonly skillMentionsFrom?: number; }) { if (input.attachments.length === 0) { const message = makeClaudeUserMessage({ text: input.text, ...(input.skills === undefined ? {} : { skills: input.skills }), + ...(input.skillMentionsFrom === undefined + ? {} + : { skillMentionsFrom: input.skillMentionsFrom }), ...(input.priority === undefined ? {} : { priority: input.priority }), ...(input.uuid === undefined ? {} : { uuid: input.uuid }), }); @@ -2861,7 +2895,7 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: const dispatch = input.skills === undefined ? undefined - : planClaudeSkillDispatch(textWithAttachmentPaths, input.skills); + : planClaudeSkillDispatch(textWithAttachmentPaths, input.skills, input.skillMentionsFrom); const content: Array = []; // Base64 adds a third to the payload. Leave room below Anthropic's 32 MB // request limit for text, instructions, and SDK framing. The SDK does not @@ -3264,6 +3298,7 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( const decodeClaudeCompactSettings = Schema.decodeUnknownEffect( Schema.fromJsonString( Schema.Struct({ + autoCompactEnabled: Schema.optionalKey(Schema.Unknown), autoCompactWindow: Schema.optionalKey(Schema.Unknown), modelSettings: Schema.optionalKey(Schema.Unknown), }), @@ -5482,6 +5517,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( modelSelection: ModelSelection, launchArgSettings: ClaudeSdkSettings | undefined, ) { + // With auto-compaction off Claude never compacts, so there is no threshold to report. + for (const name of ["DISABLE_COMPACT", "DISABLE_AUTO_COMPACT"]) { + const value = yield* resolveClaudeEnv(cwd, launchArgSettings, name); + if (isClaudeEnvTruthy(value)) return ""; + } + const launchEnabled = launchArgSettings?.autoCompactEnabled; + let autoCompactEnabled = typeof launchEnabled === "boolean" ? launchEnabled : undefined; + for (const settingsFile of yield* settingsFilesByPrecedence(cwd)) { + if (autoCompactEnabled !== undefined) break; + const settings = yield* fileSystem.readFileString(settingsFile).pipe( + Effect.flatMap(decodeClaudeCompactSettings), + Effect.orElseSucceed(() => undefined), + ); + if (typeof settings?.autoCompactEnabled === "boolean") { + autoCompactEnabled = settings.autoCompactEnabled; + } + } + if (autoCompactEnabled === false) return ""; if (adapterOptions.settings.autoCompactWindow !== "") return adapterOptions.settings.autoCompactWindow; const validWindow = (value: unknown) => { @@ -5810,6 +5863,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const scheduledWakeupsByNativeThread = new Map(); // Native threads whose current turn Claude started by itself inside the early-fire margin. const possibleEarlyWakeNativeThreads = new Set(); + // Turn starts a due or nearly due ScheduleWakeup may have caused. + const possibleScheduledWakeStarts = new WeakSet(); + // A Monitor event wakes Claude with a bare turn and no task_notification frame, so the + // only evidence is a turn start while a Monitor runs that no ScheduleWakeup explains. + const monitorEventWakeStarts = new WeakSet(); const trackClaudeScheduledWakeup = Effect.fnUntraced(function* ( nativeThreadId: string, message: SDKMessage, @@ -5818,6 +5876,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const scheduledFor = scheduledWakeupsByNativeThread.get(nativeThreadId); if (scheduledFor === undefined) return; const now = DateTime.toEpochMillis(yield* DateTime.now); + if (now >= scheduledFor - CLAUDE_SCHEDULED_WAKEUP_EARLY_FIRE_MARGIN_MS) { + possibleScheduledWakeStarts.add(message); + } if (now >= scheduledFor) { scheduledWakeupsByNativeThread.delete(nativeThreadId); } else if ( @@ -5900,6 +5961,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( for (const [taskId] of endedIds) tasks.delete(taskId); return [tasks, { ...current, tasks }] as const; }); + const hasRunningClaudeMonitor = (nativeThreadId: string) => + Ref.get(claudeMonitors).pipe( + Effect.map((current) => + [...current.tasks.values()].some((task) => task.nativeThreadId === nativeThreadId), + ), + ); + // The buffer's first wake is a Monitor event: its result, once known, is a task notification's. + const isClaudeMonitorEventWake = (messages: ReadonlyArray) => { + const startIndex = messages.findIndex(isClaudeTurnStartMessage); + const start = messages[startIndex]; + if (start === undefined || !monitorEventWakeStarts.has(start)) return false; + const result = messages + .slice(startIndex + 1) + .find( + (entry) => entry.type === "result" && !isClaudeEmptyTaskNotificationSuccess(entry), + ); + return result === undefined || isClaudeTaskNotificationOriginResult(result); + }; const claudeTaskOutcome = (status: "completed" | "failed" | "stopped") => status === "completed" ? "completed" : status === "stopped" ? "cancelled" : "failed"; // Reads the roster, so call it before the notification clears the task from it. @@ -5941,6 +6020,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly token: symbol; readonly end: SDKMessage | undefined; readonly retired: "unopened" | "opened" | null; + // Offered as Monitor progress, which starts no delegated follow-up. + readonly monitorEvent: boolean; } >(), ); @@ -9485,6 +9566,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (isClaudeRootTurnFrame(message)) { yield* forgetAbsorbedWake(wakeInput.nativeThreadId); } + if ( + isWakeTurnStart && + !possibleScheduledWakeStarts.has(message) && + (yield* hasRunningClaudeMonitor(wakeInput.nativeThreadId)) + ) { + monitorEventWakeStarts.add(message); + } yield* Ref.update(wakeBuffers, (current) => { const existing = current.get(wakeInput.nativeThreadId); const updated = new Map(current); @@ -9541,6 +9629,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId); const token = Symbol(nativeThreadId); + // A recorded report names finished work, which outranks Monitor progress. + const monitorEvent = + ((yield* Ref.get(wakeReportsByNativeThread)).reports.get(nativeThreadId)?.size ?? 0) === + 0 && isClaudeMonitorEventWake(buffered?.messages ?? []); const shouldOffer = yield* Ref.modify(requestedContinuations, (current) => { if (current.has(nativeThreadId)) { return [false, current] as const; @@ -9552,6 +9644,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (entry) => entry.type === "result" && !isClaudeEmptyTaskNotificationSuccess(entry), ), retired: null, + monitorEvent, }); return [true, updated] as const; }); @@ -9560,7 +9653,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const reports = yield* takeWakeReports(nativeThreadId); const notification = backgroundWorkNotification( - [...(reports?.values() ?? [])].map((entry) => entry.report), + monitorEvent + ? [{ kind: "monitor", outcome: "updated" }] + : [...(reports?.values() ?? [])].map((entry) => entry.report), ); yield* Effect.logInfo("orchestration-v2.claude-wake-turn-detected", { providerSessionId: input.providerSessionId, @@ -9716,9 +9811,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // After a retired wake releases its latch, a wake buffered behind it has had no offer of its own. const offerBufferedWake = Effect.fnUntraced(function* (nativeThreadId: string) { const messages = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + if (yield* holdsBufferedWake(nativeThreadId, messages)) + yield* offerWakeContinuation(nativeThreadId); + }); + + const holdsBufferedWake = Effect.fnUntraced(function* ( + nativeThreadId: string, + messages: ReadonlyArray, + ) { const reports = (yield* Ref.get(wakeReportsByNativeThread)).reports.get(nativeThreadId); // A lifecycle result ends no turn, and closes the root frames before it. - const hasWake = + return ( messages .slice(messages.findLastIndex(isClaudeEmptyTaskNotificationSuccess) + 1) .some(isClaudeRootTurnFrame) || @@ -9728,8 +9831,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (entry.type === "system" && entry.subtype === "task_notification" && reports?.get(entry.task_id)?.report.kind === "subagent"), - ); - if (hasWake) yield* offerWakeContinuation(nativeThreadId); + ) + ); }); // Untraced like the roster message handling: it runs for every streamed roster change. @@ -12165,8 +12268,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, + queryPolicy: Effect.Success>, ) { - const queryPolicy = yield* queryPolicyForTurn(turnInput); const mcpSession = yield* mcpSessions.read(turnInput.threadId); const mcpOverrides = claudeMcpQueryOverrides({ mcpSession, @@ -12243,18 +12346,31 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } // Read before closing the live process so a bad path leaves it running. - const appendSystemPromptFileText = yield* readClaudeAppendSystemPromptFile({ - settings: adapterOptions.settings, - cwd: turnInput.runtimePolicy.cwd, - fileSystem, - path, - }); - const launchArgSettings = yield* readClaudeLaunchArgSettings({ - settings: adapterOptions.settings, - cwd: turnInput.runtimePolicy.cwd, - fileSystem, - path, - }); + const [appendSystemPromptFileText, { launchArgSettings, toolSearchOverride }] = + yield* Effect.all( + [ + readClaudeAppendSystemPromptFile({ + settings: adapterOptions.settings, + cwd: turnInput.runtimePolicy.cwd, + fileSystem, + path, + }), + Effect.gen(function* () { + const launchArgSettings = yield* readClaudeLaunchArgSettings({ + settings: adapterOptions.settings, + cwd: turnInput.runtimePolicy.cwd, + fileSystem, + path, + }); + const toolSearchOverride = yield* resolveToolSearchOverride( + turnInput.runtimePolicy.cwd, + launchArgSettings, + ); + return { launchArgSettings, toolSearchOverride }; + }), + ], + { concurrency: "unbounded" }, + ); const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; clearedNativeSessions.delete(nativeThreadId); if (existing !== null) { @@ -12273,10 +12389,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; - const toolSearchOverride = yield* resolveToolSearchOverride( - turnInput.runtimePolicy.cwd, - launchArgSettings, - ); const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, modelCatalog, @@ -12555,30 +12667,48 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const used = turnInput.providerThread.nativeMetadata?.claudeAttachmentBudget; pdfBudgets.set(nativeThreadId, makeClaudeAttachmentBudget(used)); } + const [skills, queryPolicy] = yield* Effect.all( + [ + promptUuid === null + ? Effect.undefined + : userInvocableSkills(turnInput.runtimePolicy.cwd), + queryPolicyForTurn(turnInput), + ], + { concurrency: "unbounded" }, + ); const pdfReservation = yield* reserveClaudePdfBudget( pdfBudgets.get(nativeThreadId)!, persistAttachmentBudget(nativeThreadId), ); + const promptText = applyClaudePromptEffortPrefix( + turnInput.message.text, + compileClaudeModelSelection(turnInput.modelSelection, modelCatalog).promptEffort, + ); const userMessage = promptUuid === null ? null : yield* makeClaudeUserMessageWithAttachments({ - text: applyClaudePromptEffortPrefix( - turnInput.message.text, - compileClaudeModelSelection(turnInput.modelSelection, modelCatalog) - .promptEffort, - ), + text: promptText, + // Mentions in inline handoff history are history, not this turn's skill. + ...(turnInput.inlineContextLength === undefined + ? {} + : { + skillMentionsFrom: + promptText.length - + turnInput.message.text.slice(turnInput.inlineContextLength).trimEnd() + .length, + }), attachments: turnInput.message.attachments, attachmentsDir, fileSystem, pdfReservation, - skills: yield* userInvocableSkills(turnInput.runtimePolicy.cwd), + ...(skills === undefined ? {} : { skills }), uuid: promptUuid, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), ); - const querySession = yield* openQuery(turnInput, nativeThreadId); + const querySession = yield* openQuery(turnInput, nativeThreadId, queryPolicy); const autoCompactWindow = yield* resolveAutoCompactWindow( querySession.settingsCwd, turnInput.modelSelection, @@ -12740,14 +12870,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* sdkMessageLock.withPermits(1)( Effect.gen(function* () { yield* attachTurn; - const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + const latch = (yield* Ref.get(requestedContinuations)).get(nativeThreadId); + // This run carries the Monitor tag, which starts no delegated follow-up, so a wake + // buffered behind the Monitor event is left for a continuation of its own. + const ownEndIndex = + latch?.monitorEvent === true && latch.end !== undefined + ? buffered.indexOf(latch.end) + : -1; + const leavesLaterWake = + ownEndIndex !== -1 && + (yield* holdsBufferedWake(nativeThreadId, buffered.slice(ownEndIndex + 1))); + const drained = leavesLaterWake ? buffered.slice(0, ownEndIndex + 1) : buffered; yield* Ref.update(requestedContinuations, (current) => { const updated = new Map(current); updated.delete(nativeThreadId); return updated; }); - const wakeAbsorbed = (yield* Ref.get(absorbedWakes)).has(nativeThreadId); - yield* forgetAbsorbedWake(nativeThreadId); + // A root frame clears the absorbed marker, so behind a split it is the later wake's + // and stays; the split wake was absorbed when its end is not a result. + const wakeAbsorbed = leavesLaterWake + ? latch?.end?.type !== "result" + : (yield* Ref.get(absorbedWakes)).has(nativeThreadId); + if (!leavesLaterWake) yield* forgetAbsorbedWake(nativeThreadId); if (drained.length === 0) { const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); @@ -12808,11 +12953,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } if (lastResult !== undefined && !emptyTurnBeforeNextTurn) { yield* handleSdkMessage({ query: querySession.query, message: lastResult }); + if (leavesLaterWake) yield* offerBufferedWake(nativeThreadId); return; } if (wakeAbsorbed || (hasOpaqueTaskNotification && !context.nativeWakeStarted)) { const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); + if (leavesLaterWake) yield* offerBufferedWake(nativeThreadId); } else if (!context.nativeWakeStarted) { yield* Effect.sleep(CLAUDE_WAKE_TURN_START_DEADLINE).pipe( Effect.andThen( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 3aca32bf41f9..4a87c0404302 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -748,6 +748,38 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro }), ); + // A native cwd makes libuv wait for chdir, which a hung mount stalls with the whole server. + it.effect("enters the cwd in the child, so a missing one fails the CLI and not the spawn", () => + Effect.gen(function* () { + const cli = fakeClaudeCli('pwd -P > "$(dirname "$0")/cwd"'); + const dir = NodePath.dirname(cli); + const workspace = NodePath.join(dir, "work space"); + NodeFS.mkdirSync(workspace); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(dir, { recursive: true, force: true })), + ); + const exitCode = (cwd: string) => + Effect.promise( + () => + new Promise((resolve) => + spawnClaudeProcessGroup({ + command: cli, + args: [], + cwd, + env: process.env, + signal: new AbortController().signal, + }).once("exit", resolve), + ), + ); + assert.equal(yield* exitCode(workspace), 0); + assert.equal( + NodeFS.readFileSync(NodePath.join(dir, "cwd"), "utf8"), + `${NodeFS.realpathSync(workspace)}\n`, + ); + assert.equal(yield* exitCode(NodePath.join(dir, "missing")), 125); + }), + ); + // A restarted server reaps recorded CLIs, so a CLI must never run before its record exists. it.effect("records the CLI before it runs and forgets it once it exits", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 09ec3034a9bc..0fc1b9a2bf82 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -9523,6 +9523,187 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each([ + "Codex completes it later", + "its thread is unloaded", + "Codex completes it after the session's Stop deadline", + "Codex completes it after a second Stop fails", + ] as const)("keeps a turn open after an unconfirmed Stop until %s", (ending) => + Effect.gen(function* () { + const nativeThreadId = "unconfirmed-stop-thread"; + const nativeTurnId = "unconfirmed-stop-turn"; + const usage = { + totalTokens: 15, + inputTokens: 10, + cachedInputTokens: 2, + outputTokens: 5, + reasoningOutputTokens: 1, + }; + const pastDeadline = ending === "Codex completes it after the session's Stop deadline"; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: `unconfirmed-stop-${ending.length}`, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }), + { + type: "expect_outbound", + label: "turn/interrupt", + frame: { + id: 4, + method: "turn/interrupt", + params: { threadId: nativeThreadId, turnId: nativeTurnId }, + }, + }, + { + type: "emit_inbound", + label: "still working", + frame: { + method: "thread/tokenUsage/updated", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt", + // A slow acknowledgement leaves the adapter's own wait running past the deadline. + ...(pastDeadline ? { afterMs: 6_000 } : {}), + frame: { id: 4, result: {} }, + }, + ...(ending === "its thread is unloaded" + ? ([ + { + type: "expect_outbound", + label: "thread/unsubscribe", + frame: { + id: 5, + method: "thread/unsubscribe", + params: { threadId: nativeThreadId }, + }, + }, + { + type: "emit_inbound", + label: "thread/unsubscribe", + frame: { id: 5, result: { status: "unsubscribed" } }, + }, + ] satisfies Array) + : ([ + ...(ending === "Codex completes it after a second Stop fails" + ? ([ + { + type: "expect_outbound", + label: "turn/interrupt/second", + frame: { + id: 5, + method: "turn/interrupt", + params: { threadId: nativeThreadId, turnId: nativeTurnId }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt/second", + frame: { id: 5, error: { code: -32603, message: "internal error" } }, + }, + ] satisfies Array) + : []), + { + type: "emit_inbound", + label: "turn/completed", + afterMs: 20_000, + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ] satisfies Array)), + ], + }), + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-unconfirmed-stop"), + text: "work", + }), + ); + const stopping = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeTurnId, + }), + }) + .pipe( + // The session manager bounds every Stop the same way. + Effect.timeoutOrElse({ + duration: "15 seconds", + orElse: () => Effect.fail({ _tag: "SessionStopDeadline" } as const), + }), + Effect.result, + Effect.forkScoped, + ); + // Codex received the interrupt and kept reporting work on the turn. + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.tokenUsage?.usedTokens === usage.totalTokens, + ), + ); + yield* TestClock.adjust(pastDeadline ? "15 seconds" : "10 seconds"); + + const stopped = yield* Fiber.join(stopping); + assert.equal( + stopped._tag === "Failure" ? stopped.failure._tag : "confirmed", + pastDeadline ? "SessionStopDeadline" : "ProviderAdapterInterruptTimeoutError", + ); + assert.deepEqual(harness.terminalEvents(), []); + + if (ending === "Codex completes it after a second Stop fails") { + const second = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeTurnId, + }), + }) + .pipe(Effect.result); + assert.equal(second._tag, "Failure"); + assert.deepEqual(harness.terminalEvents(), []); + } + if (ending === "its thread is unloaded") { + yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); + } else { + yield* TestClock.adjust("20 seconds"); + } + yield* harness.awaitTerminal(1); + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["interrupted"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + describe("native goals", () => { const nativeThreadId = "goal-thread"; const objective = "Ship the feature"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index efa5d0a9c28f..e26b3dc5860c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -89,6 +89,7 @@ import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Function from "effect/Function"; @@ -159,6 +160,8 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" const CODEX_PROVIDER = ProviderDriverKind.make("codex"); const CODEX_CAPACITY_MAX_RETRIES = 5; +// How long Stop waits for Codex to end the turn before the user is offered Force stop. +const CODEX_STOP_CONFIRM_TIMEOUT_MS = 10_000; // Codex can announce a retry and never resume. Recovered retries resume model output within ~75s. const CODEX_PROVIDER_RETRY_STALL_TIMEOUT = "10 minutes"; export const CODEX_DRIVER_KIND = CODEX_PROVIDER; @@ -7896,13 +7899,30 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( // unarchives it on the next turn. if (unloadInput.archive === true) { yield* client.request("thread/archive", { threadId: nativeThreadId }); - return; + } else { + // A resume may already wait on the pending entry; replacing it would strand that wait. + if (!threadClosings.has(nativeThreadId)) { + threadClosings.set(nativeThreadId, yield* Deferred.make()); + } + yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); } - // A resume may already wait on the pending entry; replacing it would strand that wait. - if (!threadClosings.has(nativeThreadId)) { - threadClosings.set(nativeThreadId, yield* Deferred.make()); + // No turn/completed arrives for an unloaded thread, so a Stop Codex never + // confirmed would otherwise keep its turn open for the life of the session. + const interrupting = yield* Ref.get(interruptingNativeTurns); + const completedAt = yield* DateTime.now; + for (const context of (yield* Ref.get(activeTurns)).values()) { + if ( + context.providerThread.id !== unloadInput.providerThread.id || + !interrupting.has(context.nativeTurnId) + ) + continue; + yield* finalizeCodexTurn({ + context, + nativeTurnId: context.nativeTurnId, + status: "interrupted", + completedAt, + }); } - yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); }, (effect, unloadInput) => effect.pipe( @@ -7990,23 +8010,30 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly context: ActiveCodexTurnContext; readonly completion: Deferred.Deferred; }> = []; + const trackedInterruptNativeTurnIds = new Set(); + let joinedPendingStop = false; + // Untraced: runs once per lineage turn inside the interruptTurn span. + const trackInterruptTarget = Effect.fnUntraced(function* ( + context: ActiveCodexTurnContext, + ) { + const fresh = yield* Deferred.make(); + // An earlier Stop may still wait on this turn; replacing its waiter would strand it. + const completion = yield* Ref.modify(turnWaiters, (current) => { + const pending = current.get(context.nativeTurnId); + return pending === undefined + ? [fresh, new Map(current).set(context.nativeTurnId, fresh)] + : [pending, current]; + }); + joinedPendingStop ||= completion !== fresh; + yield* Ref.update(interruptingNativeTurns, (current) => + new Set(current).add(context.nativeTurnId), + ); + interruptTargets.push({ context, completion }); + trackedInterruptNativeTurnIds.add(context.nativeTurnId); + }); for (const context of interruptTargetContexts) { - interruptTargets.push({ context, completion: yield* Deferred.make() }); + yield* trackInterruptTarget(context); } - yield* Ref.update(turnWaiters, (current) => { - const updated = new Map(current); - for (const target of interruptTargets) { - updated.set(target.context.nativeTurnId, target.completion); - } - return updated; - }); - yield* Ref.update(interruptingNativeTurns, (current) => { - const updated = new Set(current); - for (const target of interruptTargets) { - updated.add(target.context.nativeTurnId); - } - return updated; - }); const registeredActiveTurns = yield* Ref.get(activeTurns); for (const target of interruptTargets) { if (registeredActiveTurns.get(target.context.nativeTurnId) !== target.context) { @@ -8039,9 +8066,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); }); - const trackedInterruptNativeTurnIds = new Set( - interruptTargets.map((target) => target.context.nativeTurnId), - ); const finalizeRemainingInterruptLineage = Effect.gen(function* () { while (true) { const newlyDiscovered: Array = []; @@ -8054,20 +8078,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (trackedInterruptNativeTurnIds.has(context.nativeTurnId)) { continue; } - const completion = yield* Deferred.make(); - interruptTargets.push({ context, completion }); - trackedInterruptNativeTurnIds.add(context.nativeTurnId); + yield* trackInterruptTarget(context); newlyDiscovered.push(context); - yield* Ref.update(turnWaiters, (current) => { - const updated = new Map(current); - updated.set(context.nativeTurnId, completion); - return updated; - }); - yield* Ref.update(interruptingNativeTurns, (current) => { - const updated = new Set(current); - updated.add(context.nativeTurnId); - return updated; - }); } return lineage; }), @@ -8130,19 +8142,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( !trackedInterruptNativeTurnIds.has(context.nativeTurnId), ); for (const context of lineage) { - const completion = yield* Deferred.make(); - interruptTargets.push({ context, completion }); - trackedInterruptNativeTurnIds.add(context.nativeTurnId); - yield* Ref.update(turnWaiters, (current) => { - const updated = new Map(current); - updated.set(context.nativeTurnId, completion); - return updated; - }); - yield* Ref.update(interruptingNativeTurns, (current) => { - const updated = new Set(current); - updated.add(context.nativeTurnId); - return updated; - }); + yield* trackInterruptTarget(context); } return lineage; }), @@ -8169,114 +8169,55 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } }); - yield* Effect.gen(function* () { - for (const target of interruptTargets) { - const context = target.context; - if (context.capacityRetryPending !== undefined) { - yield* finalizeCodexTurn({ - context, - nativeTurnId: context.nativeTurnId, - status: "interrupted", - completedAt: yield* DateTime.now, - }); - continue; - } - // Null start timestamps ACK queued turns; interruption must wait for turn/started. - if (context.nativeStartReady !== undefined) { - const ready = yield* Deferred.await(context.nativeStartReady).pipe( - Effect.timeoutOption("10 seconds"), - ); - if (Option.isNone(ready)) { - return yield* toProtocolError( - "Codex did not start the queued turn within 10 seconds; Stop could not be delivered.", - ); - } - } - if ((yield* Ref.get(activeTurns)).get(context.nativeTurnId) !== context) { - continue; - } - yield* client.request("turn/interrupt", { - threadId: yield* getNativeThreadId(context.providerThread), - turnId: context.nativeTurnId, - }); - } - const containedTerminalKeys = new Set(); - const attemptedTerminalKeys = new Set(); - const collectTrackedTerminals = Effect.fn("CodexAdapterV2.collectTrackedTerminals")( - function* (targets: typeof interruptTargets) { - const trackedTerminals = new Map< - string, - { readonly nativeThreadId: string; readonly processId: string } - >(); - const runningItems = yield* Ref.get(runningCommandItemsByTurn); - for (const target of targets) { - const nativeThreadId = yield* getNativeThreadId( - target.context.providerThread, - ); - const items = runningItems.get(target.context.nativeTurnId); - for (const item of items?.values() ?? []) { - if (item.processId !== undefined) { - trackedTerminals.set(`${nativeThreadId}:${item.processId}`, { - nativeThreadId, - processId: item.processId, - }); - } + const containedTerminalKeys = new Set(); + const attemptedTerminalKeys = new Set(); + const collectTrackedTerminals = Effect.fn("CodexAdapterV2.collectTrackedTerminals")( + function* (targets: typeof interruptTargets) { + const trackedTerminals = new Map< + string, + { readonly nativeThreadId: string; readonly processId: string } + >(); + const runningItems = yield* Ref.get(runningCommandItemsByTurn); + for (const target of targets) { + const nativeThreadId = yield* getNativeThreadId(target.context.providerThread); + const items = runningItems.get(target.context.nativeTurnId); + for (const item of items?.values() ?? []) { + if (item.processId !== undefined) { + trackedTerminals.set(`${nativeThreadId}:${item.processId}`, { + nativeThreadId, + processId: item.processId, + }); } } - return trackedTerminals; - }, - ); - const terminateTrackedTerminals = Effect.fn( - "CodexAdapterV2.terminateTrackedTerminals", - )(function* (targets: typeof interruptTargets) { - const trackedTerminals = yield* collectTrackedTerminals(targets); - const pendingTerminals = Array.from(trackedTerminals.entries()) - .filter(([key]) => !containedTerminalKeys.has(key)) - .sort( - ([leftKey], [rightKey]) => - Number(attemptedTerminalKeys.has(leftKey)) - - Number(attemptedTerminalKeys.has(rightKey)), - ); - return yield* Effect.forEach( - pendingTerminals, - ([key, { nativeThreadId, processId }]) => - Effect.gen(function* () { - attemptedTerminalKeys.add(key); - yield* terminateBackgroundTerminal(nativeThreadId, processId); - containedTerminalKeys.add(key); - }).pipe( - Effect.as({ success: true as const }), - Effect.catch((error) => Effect.succeed({ success: false as const, error })), - ), + } + return trackedTerminals; + }, + ); + const terminateTrackedTerminals = Effect.fn( + "CodexAdapterV2.terminateTrackedTerminals", + )(function* (targets: typeof interruptTargets) { + const trackedTerminals = yield* collectTrackedTerminals(targets); + const pendingTerminals = Array.from(trackedTerminals.entries()) + .filter(([key]) => !containedTerminalKeys.has(key)) + .sort( + ([leftKey], [rightKey]) => + Number(attemptedTerminalKeys.has(leftKey)) - + Number(attemptedTerminalKeys.has(rightKey)), ); - }); - - const [, completed] = yield* Effect.all( - [ - terminateTrackedTerminals(interruptTargets), - Effect.forEach( - interruptTargets, - (target) => Deferred.await(target.completion), - { concurrency: "unbounded", discard: true }, - ).pipe(Effect.timeoutOption("10 seconds")), - ], - { concurrency: "unbounded" }, + return yield* Effect.forEach( + pendingTerminals, + ([key, { nativeThreadId, processId }]) => + Effect.gen(function* () { + attemptedTerminalKeys.add(key); + yield* terminateBackgroundTerminal(nativeThreadId, processId); + containedTerminalKeys.add(key); + }).pipe( + Effect.as({ success: true as const }), + Effect.catch((error) => Effect.succeed({ success: false as const, error })), + ), ); - if (Option.isNone(completed)) { - for (const target of interruptTargets) { - const context = target.context; - if ((yield* Ref.get(activeTurns)).get(context.nativeTurnId) !== context) { - continue; - } - yield* Effect.logWarning("orchestration-v2.codex-interrupt-timeout", { - providerSessionId: input.providerSessionId, - providerThreadId: context.providerThread.id, - providerTurnId: context.providerTurnId, - nativeTurnId: context.nativeTurnId, - }); - } - } - + }); + const settleInterruptedLineage = Effect.gen(function* () { yield* interruptLateDescendants; yield* finalizeRemainingInterruptLineage; @@ -8316,10 +8257,114 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } }), ); + }); + // Codex may still be working, so a root stays open and marked interrupting. + // Whatever ends it later (Codex, Force stop) settles the rest of this Stop. + const deferToActiveRoots = Effect.gen(function* () { + const registered = yield* Ref.get(activeTurns); + const roots = interruptTargets.filter( + ({ context }) => + lineageRoots.includes(context) && + registered.get(context.nativeTurnId) === context, + ); + if (roots.length === 0) return false; + for (const { context } of roots) { + yield* Effect.logWarning("orchestration-v2.codex-interrupt-timeout", { + providerSessionId: input.providerSessionId, + providerThreadId: context.providerThread.id, + providerTurnId: context.providerTurnId, + nativeTurnId: context.nativeTurnId, + }); + } + yield* Effect.forEach(roots, (target) => Deferred.await(target.completion), { + concurrency: "unbounded", + discard: true, + }).pipe( + Effect.andThen(settleInterruptedLineage), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logWarning("orchestration-v2.codex-late-stop-settle-failed", { + providerSessionId: input.providerSessionId, + cause, + }), + ), + Effect.ensuring(cleanupInterruptState), + Effect.forkIn(scope), + ); + return true; + }); + + const unconfirmed = yield* Effect.gen(function* () { + for (const target of interruptTargets) { + const context = target.context; + if (context.capacityRetryPending !== undefined) { + yield* finalizeCodexTurn({ + context, + nativeTurnId: context.nativeTurnId, + status: "interrupted", + completedAt: yield* DateTime.now, + }); + continue; + } + // Null start timestamps ACK queued turns; interruption must wait for turn/started. + if (context.nativeStartReady !== undefined) { + const ready = yield* Deferred.await(context.nativeStartReady).pipe( + Effect.timeoutOption("10 seconds"), + ); + if (Option.isNone(ready)) { + return yield* toProtocolError( + "Codex did not start the queued turn within 10 seconds; Stop could not be delivered.", + ); + } + } + if ((yield* Ref.get(activeTurns)).get(context.nativeTurnId) !== context) { + continue; + } + yield* client.request("turn/interrupt", { + threadId: yield* getNativeThreadId(context.providerThread), + turnId: context.nativeTurnId, + }); + } + const [, completed] = yield* Effect.all( + [ + terminateTrackedTerminals(interruptTargets), + Effect.forEach( + interruptTargets, + (target) => Deferred.await(target.completion), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.timeoutOption(CODEX_STOP_CONFIRM_TIMEOUT_MS)), + ], + { concurrency: "unbounded" }, + ); + if (Option.isSome(completed) || !(yield* deferToActiveRoots)) { + yield* settleInterruptedLineage; + return false; + } + return true; }).pipe( - Effect.onError(() => finalizeRemainingInterruptLineage), - Effect.ensuring(cleanupInterruptState), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return exit.value ? Effect.void : cleanupInterruptState; + const abandon = finalizeRemainingInterruptLineage.pipe( + Effect.ensuring(cleanupInterruptState), + ); + // Neither a caller's deadline nor a failed retry of a pending Stop confirms + // that the turn ended. + return Cause.hasInterruptsOnly(exit.cause) || joinedPendingStop + ? Effect.flatMap(deferToActiveRoots, (deferred) => + deferred ? Effect.void : abandon, + ) + : abandon; + }), ); + if (unconfirmed) { + return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({ + driver: CODEX_PROVIDER, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + timeoutMs: CODEX_STOP_CONFIRM_TIMEOUT_MS, + }); + } }, (effect, requestedInput) => Effect.acquireUseRelease( @@ -8338,14 +8383,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( else goalStops.delete(nativeThreadId); }), ).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: CODEX_PROVIDER, - providerThreadId: requestedInput.providerThread.id, - providerTurnId: requestedInput.providerTurnId, - cause, - }), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterInterruptTimeoutError" + ? cause + : new ProviderAdapter.ProviderAdapterInterruptError({ + driver: CODEX_PROVIDER, + providerThreadId: requestedInput.providerThread.id, + providerTurnId: requestedInput.providerTurnId, + cause, + }), ), ), ), diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index d469ad6c8108..4cbdbf78b00e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1561,8 +1561,11 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }, }, }); - const callAgain = (input: Record): ReadonlyArray => { - const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-again" }; + const callAgain = ( + input: Record, + { id = "call-again", writesRules = true } = {}, + ): ReadonlyArray => { + const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id }; return [ event("session.tool.input.started", { ...tool, name: "subagent" }), event("session.tool.called", { @@ -1575,20 +1578,25 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ...tool, metadata: { sessionID: CHILD, status: "running" }, }), - out("session.update", { sessionID: CHILD, permissions: "" }), - reply("session.update", null), + // The adapter writes a child's rules only when they changed. + ...(writesRules + ? [ + out("session.update", { sessionID: CHILD, permissions: "" }), + reply("session.update", null), + ] + : []), returnedRunning(tool.id), ]; }; - const childReport = (inboxID: string, text: string) => + const childReport = (inboxID: string, text: string, description = "Sleep") => event("session.inbox.enqueued", { inboxID, sessionID: SESSION, item: { type: "synthetic", payload: { - text: `\n${text}\n`, - description: "Sleep", + text: `\n${text}\n`, + description, metadata: { source: "subagent", childID: CHILD, agent: "General", state: "completed" }, }, delivery: "steer", @@ -1600,6 +1608,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ) => Effect.gen(function* () { const calls = new Map(); + const callsById = new Map(); const childTurns = new Map(); const callsAtChildTurnStart = new Map< string, @@ -1610,6 +1619,12 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { yield* runtime.events.pipe( Stream.tap((event) => Effect.gen(function* () { + if (event.type === "subagent.updated") { + callsById.set(event.subagent.nativeTaskRef?.nativeId ?? "", { + status: event.subagent.status, + result: event.subagent.result, + }); + } if (event.type === "subagent.updated" && event.subagent.title !== null) { calls.set(event.subagent.title, { status: event.subagent.status, @@ -1648,7 +1663,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { yield* until(check); }); }); - return { calls, callsAtChildTurnStart, childTurns, state, until }; + return { calls, callsById, callsAtChildTurnStart, childTurns, state, until }; }); const launchedAndRunning = [ ...backgroundLaunch(CHILD), @@ -1861,6 +1876,119 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }), ); + const childSaid = (text: string, run = 1) => + event("session.text.ended", { + sessionID: CHILD, + assistantMessageID: `msg_child_${run}`, + ordinal: 0, + text, + }); + it.effect.each([ + { + // regression: #26319 the report settled the older run and left the resumed call running + name: "an earlier run's report never arrived", + replay: [ + ...launchedAndRunning, + ...callAgain({ background: true }), + childSaid("FIRST_OK"), + event("session.execution.succeeded", { sessionID: CHILD }), + ...callAgain({ background: true }, { id: "call-later", writesRules: false }), + event("session.execution.started", { sessionID: CHILD }), + childSaid("SECOND_OK", 2), + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.execution.succeeded", { sessionID: SESSION }), + childReport("msg_report_2", "SECOND_OK", "Again"), + ], + expected: { + "call-background": { status: "completed", result: "FIRST_OK" }, + "call-again": { status: "completed", result: "FIRST_OK" }, + "call-later": { status: "completed", result: "SECOND_OK" }, + }, + }, + { + name: "both runs' reports arrive after the runs ended", + replay: [ + ...launchedAndRunning, + event("session.execution.succeeded", { sessionID: CHILD }), + ...callAgain({ background: true, description: "Sleep" }), + event("session.execution.started", { sessionID: CHILD }), + event("session.execution.succeeded", { sessionID: CHILD }), + childReport("msg_report_1", "FIRST_OK"), + childReport("msg_report_2", "SECOND_OK"), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + expected: { + "call-background": { status: "completed", result: "FIRST_OK" }, + "call-again": { status: "completed", result: "SECOND_OK" }, + }, + }, + { + name: "two runs with the same answer report late", + replay: [ + ...launchedAndRunning, + childSaid("DONE"), + event("session.execution.succeeded", { sessionID: CHILD }), + ...callAgain({ background: true }), + event("session.execution.started", { sessionID: CHILD }), + childSaid("DONE", 2), + event("session.execution.succeeded", { sessionID: CHILD }), + childReport("msg_report_1", "DONE"), + ...callAgain({ background: true }, { id: "call-later", writesRules: false }), + event("session.execution.started", { sessionID: CHILD }), + childReport("msg_report_2", "DONE", "Again"), + childSaid("THIRD_OK", 3), + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.execution.succeeded", { sessionID: SESSION }), + childReport("msg_report_3", "THIRD_OK", "Again"), + ], + expected: { + "call-background": { status: "completed", result: "DONE" }, + "call-again": { status: "completed", result: "DONE" }, + "call-later": { status: "completed", result: "THIRD_OK" }, + }, + }, + { + name: "the call it joined already returned", + replay: [ + ...backgroundLaunch(CHILD), + event("session.execution.started", { sessionID: CHILD }), + ...callAgain({ background: true }), + event("session.execution.succeeded", { sessionID: CHILD }), + event( + "session.tool.success", + { + sessionID: SESSION, + assistantMessageID: "msg_assistant", + id: "call-background", + content: [{ type: "text", text: "FIRST_OK" }], + metadata: { sessionID: CHILD, status: "completed" }, + executed: true, + }, + undefined, + 2, + ), + event("session.execution.succeeded", { sessionID: SESSION }), + childReport("msg_report", "CHILD_OK", "Again"), + ], + expected: { + "call-background": { status: "completed", result: "FIRST_OK" }, + "call-again": { status: "completed", result: "CHILD_OK" }, + }, + }, + ])("settles a resumed subagent's calls when $name", ({ replay, expected }) => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed(replay); + const watch = yield* watchCalls(runtime, thread); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.until( + () => + watch.state.roster?.length === 0 && + Object.keys(expected).every((id) => watch.callsById.get(id)?.status === "completed"), + ); + assert.deepEqual(Object.fromEntries(watch.callsById), expected); + }), + ); + const MIDDLE = "ses_middle0000000000000000000"; const DEEP = "ses_grandchild00000000000000"; const toolOf = (session: string, id: string) => ({ diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index c37b206f35a8..3e0fbe95e8aa 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -410,6 +410,7 @@ export const layer: Layer.Layer< .captureCheckpoint({ cwd: input.scope.cwd, checkpointRef, + ...(input.ordinalWithinScope > 0 ? { previousCheckpointRef } : {}), }) .pipe( Effect.as("ready" as const), diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 85babb8a869b..14a803b1d1d6 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -15,6 +15,7 @@ import * as Effect from "effect/Effect"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; +import { makeGrokAcpAdapterFlavor } from "@t3tools/provider-grok/testing"; import * as CommandPolicy from "./CommandPolicy.ts"; const commandId = CommandId.make("command-policy-test"); @@ -211,6 +212,28 @@ it("queues a steer for a provider that cannot steer either way", () => { ); }); +// regression: #27014 a Grok follow-up cancels the foreground tool the user wanted to finish +it("does not steer a Grok follow-up by interrupting its running turn", () => { + const grok = makeGrokAcpAdapterFlavor( + { makeRuntime: () => Effect.never } as unknown as Parameters< + typeof makeGrokAcpAdapterFlavor + >[0], + "/home/ada", + ).capabilities; + const projection = dispatchProjection(grok); + for (const deliveryIntent of ["auto", "steer"] as const) { + const mode = CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + deliveryIntent, + ); + assert.isFalse( + mode.type === "steer_active" && !grok.turns.supportsActiveSteering, + `${deliveryIntent} follow-up resolved to ${mode.type} by interrupt-restart`, + ); + } +}); + it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) => { const projection = dispatchProjection(baseCapabilities); const maintenance = { diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 426da22121e3..22827388bffa 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2635,11 +2635,19 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( threadId: child.childThreadId, messageId: wakeMessageId, text: "Background activity updated", - notification: { - source: { kind: "command" }, - outcome: "completed", - summary: 'Command "sleep 60" finished', - }, + // A monitor that ends is finished work too; only its per-line updates are progress. + notification: + delivery === "live" + ? { + source: { kind: "monitor" }, + outcome: "completed", + summary: 'Monitor "sleep 60" finished', + } + : { + source: { kind: "command" }, + outcome: "completed", + summary: 'Command "sleep 60" finished', + }, attachments: [], dispatchMode: { type: "queue_after_active" }, }); @@ -2937,6 +2945,491 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( }; }); + it.effect("holds a follow-up open while the child's own subagent still runs", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make("thread:followup-grandchild-parent"); + const projectId = ProjectId.make("project:followup-grandchild"); + const parentRunId = RunId.make("run:followup-grandchild-parent"); + const rootNodeId = NodeId.make("node:followup-grandchild-parent"); + yield* seedParentWithTerminalTask({ + threadId: parentThreadId, + projectId, + runId: parentRunId, + rootNodeId, + taskId: NodeId.make("node:followup-grandchild-seed"), + deliveryState: "acknowledged", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId, + projectId, + parentRunId, + rootNodeId, + name: "followup-grandchild-child", + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + yield* orchestrator.recoverDelegatedTasks; + assert.equal( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === child.taskId, + )?.status, + "completed", + ); + + const followupMessageId = MessageId.make("message:followup-grandchild-send"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("command:followup-grandchild-send"), + threadId: child.childThreadId, + senderThreadId: parentThreadId, + messageId: followupMessageId, + text: "Now delegate the next step.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const followupRun = (yield* orchestrator.getThreadProjection( + child.childThreadId, + )).runs.find((run) => run.userMessageId === followupMessageId)!; + assert.isDefined(followupRun.delegatedTaskId); + const followupTaskId = followupRun.delegatedTaskId!; + + const grandchildId = NodeId.make("node:followup-grandchild-grandchild"); + const grandchild = { + id: grandchildId, + threadId: child.childThreadId, + runId: followupRun.id, + parentNodeId: NodeId.make("node:followup-grandchild-child-root"), + origin: "app_owned" as const, + createdBy: "agent" as const, + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId: ThreadId.make("thread:followup-grandchild-grandchild"), + nativeTaskRef: null, + prompt: "Do the next step.", + title: null, + model: null, + completionWake: "always" as const, + status: "running" as const, + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }; + yield* eventSink.write({ + commandId: reconcileCommandId("followup-grandchild-turn-ended"), + events: [ + { + id: EventId.make("event:followup-grandchild-started"), + type: "subagent.updated", + threadId: child.childThreadId, + runId: followupRun.id, + nodeId: grandchildId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: grandchild, + }, + { + id: EventId.make("event:followup-grandchild-run-completed"), + type: "run.updated", + threadId: child.childThreadId, + runId: followupRun.id, + occurredAt: now, + payload: { ...followupRun, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + + const waiting = yield* orchestrator.getThreadProjection(parentThreadId); + const heldFollowup = waiting.subagents.find((task) => task.id === followupTaskId); + assert.isNull( + heldFollowup?.result ?? null, + "the follow-up closed while the child's own subagent was still running", + ); + assert.equal(heldFollowup?.status, "running"); + assert.equal( + waiting.contextTransfers.filter( + (transfer) => + transfer.type === "subagent_result" && + transfer.sourceThreadId === child.childThreadId, + ).length, + 1, + ); + + // Another turn in the child does not release the hold while the grandchild runs. + for (const status of ["running", "completed"] as const) { + yield* eventSink.write({ + commandId: reconcileCommandId(`followup-grandchild-user-${status}`), + events: [ + runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:followup-grandchild-user"), + ordinal: followupRun.ordinal + 1, + status, + now, + }), + ], + }); + yield* orchestrator.recoverDelegatedTasks; + assert.isNull( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + )?.result ?? null, + `the follow-up closed while another child run was ${status}`, + ); + } + + // The grandchild's completion wakes the child in a run without a delegatedTaskId. + const wakeRunId = RunId.make("run:followup-grandchild-wake"); + const wakeRun = (status: "running" | "completed") => + runEvent({ + threadId: child.childThreadId, + runId: wakeRunId, + ordinal: followupRun.ordinal + 2, + status, + now, + }); + yield* eventSink.write({ + commandId: reconcileCommandId("followup-grandchild-settled"), + events: [ + { + id: EventId.make("event:followup-grandchild-completed"), + type: "subagent.updated", + threadId: child.childThreadId, + runId: followupRun.id, + nodeId: grandchildId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...grandchild, + status: "completed", + result: "grandchild finished", + completionDelivery: { state: "acknowledged", observedByRunId: wakeRunId }, + completedAt: now, + }, + }, + { + id: EventId.make("event:followup-grandchild-wake-message"), + type: "message.updated", + threadId: child.childThreadId, + runId: wakeRunId, + occurredAt: now, + payload: { + id: MessageId.make(`message:${wakeRunId}`), + threadId: child.childThreadId, + runId: wakeRunId, + nodeId: null, + role: "user", + text: "Delegated task finished.", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { + parentRunId: followupRun.id, + generation: 1, + taskIds: [grandchildId], + }, + createdAt: now, + updatedAt: now, + }, + }, + wakeRun("running"), + ], + }); + yield* orchestrator.recoverDelegatedTasks; + const waking = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + ); + assert.isNull(waking?.result ?? null, "the follow-up closed before the child's wake ended"); + + yield* eventSink.write({ + commandId: reconcileCommandId("followup-grandchild-wake-finished"), + events: [ + { + id: EventId.make("event:followup-grandchild-wake-result"), + type: "message.updated", + threadId: child.childThreadId, + runId: wakeRunId, + occurredAt: now, + payload: { + id: MessageId.make("message:followup-grandchild-wake-result"), + threadId: child.childThreadId, + runId: wakeRunId, + nodeId: null, + role: "assistant", + text: "The grandchild finished the next step.", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + wakeRun("completed"), + ], + }); + yield* orchestrator.recoverDelegatedTasks; + const settled = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + ); + assert.equal(settled?.status, "completed"); + assert.equal(settled?.result, "The grandchild finished the next step."); + }), + ); + + it.effect.each(["woken", "cancelled"] as const)( + "holds a follow-up open while the child's awaited background command runs (%s)", + (ending) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make(`thread:followup-command-parent-${ending}`); + const projectId = ProjectId.make(`project:followup-command-${ending}`); + const parentRunId = RunId.make(`run:followup-command-parent-${ending}`); + const rootNodeId = NodeId.make(`node:followup-command-parent-${ending}`); + yield* seedParentWithTerminalTask({ + threadId: parentThreadId, + projectId, + runId: parentRunId, + rootNodeId, + taskId: NodeId.make(`node:followup-command-seed-${ending}`), + deliveryState: "acknowledged", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId, + projectId, + parentRunId, + rootNodeId, + name: `followup-command-child-${ending}`, + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + yield* orchestrator.recoverDelegatedTasks; + + const followupMessageId = MessageId.make(`message:followup-command-send-${ending}`); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make(`command:followup-command-send-${ending}`), + threadId: child.childThreadId, + senderThreadId: parentThreadId, + messageId: followupMessageId, + text: "Start the check in the background, wait on it, and reply when it ends.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const followupRun = (yield* orchestrator.getThreadProjection( + child.childThreadId, + )).runs.find((run) => run.userMessageId === followupMessageId)!; + const followupTaskId = followupRun.delegatedTaskId!; + const parent = yield* orchestrator.getThreadProjection(parentThreadId); + + // The child awaits a background command, replies with an interim note and ends its turn. + yield* eventSink.write({ + commandId: reconcileCommandId(`followup-command-turn-ended-${ending}`), + events: [ + { + id: EventId.make(`event:followup-command-awaited-${ending}`), + type: "provider-thread.updated", + threadId: child.childThreadId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...parent.providerThreads[0]!, + id: ProviderThreadId.make(`provider-thread:followup-command-child-${ending}`), + appThreadId: child.childThreadId, + pendingBackgroundTasks: [ + { taskId: "sleep-90", kind: "command" as const, awaited: true as const }, + ], + }, + }, + { + id: EventId.make(`event:followup-command-interim-${ending}`), + type: "message.updated", + threadId: child.childThreadId, + runId: followupRun.id, + occurredAt: now, + payload: { + id: MessageId.make(`message:followup-command-interim-${ending}`), + threadId: child.childThreadId, + runId: followupRun.id, + nodeId: null, + role: "assistant", + text: "INTERIM", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make(`event:followup-command-run-completed-${ending}`), + type: "run.updated", + threadId: child.childThreadId, + runId: followupRun.id, + occurredAt: now, + payload: { ...followupRun, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + + const held = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + ); + assert.isNull( + held?.result ?? null, + "the follow-up closed with its interim reply while the awaited command still ran", + ); + assert.equal(held?.status, "running"); + + if (ending === "cancelled") { + yield* orchestrator.cancelDelegatedTask({ + parentThreadId, + taskId: followupTaskId, + command: { + type: "thread.stop", + commandId: CommandId.make(`command:followup-command-cancel-${ending}`), + threadId: child.childThreadId, + stopDelegatedTasks: false, + }, + stopDescendants: () => Effect.void, + }); + yield* eventSink.write({ + commandId: reconcileCommandId(`followup-command-stopped-${ending}`), + events: [ + { + id: EventId.make(`event:followup-command-stopped-${ending}`), + type: "provider-thread.updated", + threadId: child.childThreadId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...parent.providerThreads[0]!, + id: ProviderThreadId.make(`provider-thread:followup-command-child-${ending}`), + appThreadId: child.childThreadId, + pendingBackgroundTasks: [], + }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + const stopped = (yield* orchestrator.getThreadProjection( + parentThreadId, + )).subagents.find((task) => task.id === followupTaskId); + assert.equal(stopped?.cancelledRunId, followupRun.id); + assert.equal(stopped?.status, "interrupted"); + return; + } + + // The finished command wakes the child; that turn continues the same follow-up. + const wakeMessageId = MessageId.make(`message:followup-command-wake-${ending}`); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make(`command:followup-command-wake-${ending}`), + threadId: child.childThreadId, + messageId: wakeMessageId, + text: "Background activity updated", + notification: { + source: { kind: "command" }, + outcome: "completed", + summary: 'Command "sleep 90" finished', + }, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const wakeRun = (yield* orchestrator.getThreadProjection(child.childThreadId)).runs.find( + (run) => run.userMessageId === wakeMessageId, + )!; + assert.equal(wakeRun.delegatedTaskId, followupTaskId); + yield* eventSink.write({ + commandId: reconcileCommandId(`followup-command-wake-ended-${ending}`), + events: [ + { + id: EventId.make(`event:followup-command-settled-${ending}`), + type: "provider-thread.updated", + threadId: child.childThreadId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...parent.providerThreads[0]!, + id: ProviderThreadId.make(`provider-thread:followup-command-child-${ending}`), + appThreadId: child.childThreadId, + pendingBackgroundTasks: [], + }, + }, + { + id: EventId.make(`event:followup-command-final-${ending}`), + type: "message.updated", + threadId: child.childThreadId, + runId: wakeRun.id, + occurredAt: now, + payload: { + id: MessageId.make(`message:followup-command-final-${ending}`), + threadId: child.childThreadId, + runId: wakeRun.id, + nodeId: null, + role: "assistant", + text: "FINAL CHECK-OK", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make(`event:followup-command-wake-completed-${ending}`), + type: "run.updated", + threadId: child.childThreadId, + runId: wakeRun.id, + occurredAt: now, + payload: { ...wakeRun, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + + const finished = yield* orchestrator.getThreadProjection(parentThreadId); + const childTasks = finished.subagents.filter( + (task) => task.childThreadId === child.childThreadId, + ); + assert.deepEqual( + childTasks.map((task) => task.id).toSorted(), + [child.taskId, followupTaskId].toSorted(), + ); + const followup = childTasks.find((task) => task.id === followupTaskId); + assert.equal(followup?.status, "completed"); + assert.equal(followup?.result, "FINAL CHECK-OK"); + }), + ); + it.effect("waits for a run the follow-up's child queued on its own thread", () => Effect.gen(function* () { const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 21afe0b0574c..f36ce5bdc564 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -2468,6 +2468,19 @@ it.effect("queued-message commands read a long thread's history without walking }), ); walks.push(...(yield* historyWalks("queued-message.promote-to-steer"))); + yield* dispatch("cancelled"); + const cancelledRun = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + assert.equal(cancelledRun.status, "queued"); + statements.splice(0); + yield* observed( + orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel:long-history-reads"), + threadId, + runId: cancelledRun.id, + }), + ); + walks.push(...(yield* historyWalks("queued-run.cancel"))); yield* observed( orchestrator.dispatch({ type: "run.interrupt", @@ -2481,7 +2494,14 @@ it.effect("queued-message commands read a long thread's history without walking const after = yield* projections.getThreadProjection(threadId); assert.deepEqual( after.messages.filter((message) => message.role === "user").map((message) => message.text), - ["Request 1", "Request 2", "Request 3", "/compact", "Request queued, edited"], + [ + "Request 1", + "Request 2", + "Request 3", + "/compact", + "Request cancelled", + "Request queued, edited", + ], ); assert.deepEqual( after.providerThreads.map((providerThread) => providerThread.id), @@ -2490,3 +2510,103 @@ it.effect("queued-message commands read a long thread's history without walking assert.deepEqual(walks, [], walks.join("\n")); }).pipe(Effect.provide(layerTest)), ); + +it.effect("a running provider-turn update reads only the thread's queued and running runs", () => + Effect.gen(function* () { + const runsReads = yield* Deferred.make["compile"]>>(); + let armed = false; + // The deferred-steer reactor runs in a fiber forked by the layer, so observe at layer level. + const observe: Statement.Transformer = (statement) => { + const compiled = statement.compile(); + return armed && /FROM orchestration_v2_projection_runs\b/.test(compiled[0]) + ? Deferred.succeed(runsReads, compiled).pipe(Effect.as(statement)) + : Effect.succeed(statement); + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const eventSink = yield* EventSink.EventSinkV2; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread:deferred-steer-reads"); + const driver = ProviderDriverKind.make("codex"); + const now = yield* DateTime.now; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-deferred-steer-reads"), + threadId, + projectId: ProjectId.make("project:deferred-steer-reads"), + title: "Deferred steer reads", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("dispatch:deferred-steer-reads"), + threadId, + messageId: MessageId.make("message:deferred-steer-reads"), + text: "Request", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const run = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + const providerThreadId = ProviderThreadId.make("provider-thread:deferred-steer-reads"); + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:deferred-steer-reads:run"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, providerThreadId, status: "running", queuePosition: null }, + }, + ], + }); + armed = true; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:deferred-steer-reads:turn"), + type: "provider-turn.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make("provider-turn:deferred-steer-reads"), + providerThreadId, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId!, + nativeTurnRef: { + driver, + nativeId: "native:deferred-steer-reads", + strength: "strong", + }, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }, + ], + }); + const [query, params] = yield* Deferred.await(runsReads); + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + // Every run the thread ever had, when only queued steers and the running run matter. + assert.isFalse( + plan.some(({ detail }) => /^SEARCH .*runs.* \(thread_id=\?\)$/.test(detail)), + `${plan.map(({ detail }) => detail).join("\n")}\n in: ${query.replace(/\s+/g, " ").trim()}`, + ); + }).pipe( + Effect.provide( + layerTest.pipe(Layer.provide(Layer.succeed(Statement.CurrentTransformer, observe))), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6d1a6411d217..5419f8f3954f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4,6 +4,7 @@ import type { OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, OrchestrationV2ThreadHistoryPage, + ProjectId, } from "@t3tools/contracts"; import { latestExecutedRun, @@ -128,7 +129,9 @@ import { cancelledBeforeStart, continuationFailedBeforePrompt, continuationStartRetryDelayMs, + delegatedTaskAwaitsWork, delegatedTaskResultRun, + delegatedTaskRuns, isRestartContinuationSource, runOutcome, } from "./RestartBackgroundNote.ts"; @@ -171,6 +174,7 @@ import { type ProjectionCheckpointContext, type OlderSettledThreadsListing, type ActiveShellIndexEntry, + type ActiveThreadListEntry, type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; @@ -430,6 +434,13 @@ export interface OrchestratorV2Shape { options: ProjectionTimelinePageOptions, ) => Effect.Effect; readonly getMessageCount: (threadId: ThreadId) => Effect.Effect; + readonly getMessagesById: ( + threadId: ThreadId, + messageIds: ReadonlyArray, + ) => Effect.Effect< + ReadonlyMap, + OrchestratorV2Error + >; readonly getTranscriptByteFloor: ( threadId: ThreadId, limit: number, @@ -495,6 +506,9 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly listActiveThreads: ( + projectId?: ProjectId, + ) => Effect.Effect, OrchestratorV2Error>; readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, ) => Effect.Effect; @@ -749,6 +763,26 @@ function cancelledAppOwnedTask( }; } +// A workspace change detaches the session, which can settle the calling run at once. Queuing its +// follow-up in a later command would let a delegated task report its result before that exists. +function metadataUpdateContinuation( + command: Extract, +): Extract | undefined { + return command.continuation === undefined + ? undefined + : { + type: "message.dispatch", + commandId: command.commandId, + threadId: command.threadId, + messageId: command.continuation.messageId, + text: command.continuation.text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "mcp", + }; +} + // Provider wakes can start follow-up work; monitor updates only report progress. // Dispatch takes the parent's lock for follow-ups because intake writes the parent. function mayStartSubagentFollowup( @@ -756,7 +790,10 @@ function mayStartSubagentFollowup( ): boolean { if (command.scheduledTaskId !== undefined || command.restartContinuationOfRunId !== undefined) return false; - if (command.notification !== undefined) return command.notification.source.kind !== "monitor"; + if (command.notification !== undefined) + return ( + command.notification.source.kind !== "monitor" || command.notification.outcome !== "updated" + ); return ( command.createdBy === "user" || command.creationSource === "mcp" || @@ -902,16 +939,23 @@ function nextQueuedRun( return queuedRunsInDeliveryOrder(projection).find((run) => run.queueHeld !== true); } +// Delegated completions, monitor notifications and unpinned scheduled deliveries never choose a +// model, so a queued one runs on the thread's current pick rather than the one frozen at queue time. function followsCurrentConversation( message: | Pick< OrchestrationV2ConversationMessage, - "createdBy" | "creationSource" | "notification" | "delegatedCompletion" + | "createdBy" + | "creationSource" + | "notification" + | "delegatedCompletion" + | "followsThreadSelection" > | undefined, ): boolean { return ( message?.delegatedCompletion !== undefined || + message?.followsThreadSelection === true || (message?.notification?.source.kind === "monitor" && !isBufferedProviderNotification(message)) ); } @@ -1267,22 +1311,35 @@ function appendContextHandoffId( return handoffId === null ? handoffIds : Array.from(new Set([...handoffIds, handoffId])); } +/** A fresh-session schedule run starts a side conversation; the thread's own one continues past it. */ +function isScheduledFreshRun( + run: Pick, + turnItems: OrchestrationV2ThreadProjection["turnItems"], +): boolean { + // Turn items, unlike messages, are not narrowed to the runs since the latest compaction. + return ( + run.freshContext === true && + turnItems.some( + (item) => + item.type === "user_message" && + item.messageId === run.userMessageId && + item.scheduledTaskId !== undefined, + ) + ); +} + /** Ordinal of the latest started fresh-context run. Runs before it never reach a provider again. */ function contextBoundaryOrdinal( - runs: ReadonlyArray< - Pick< - OrchestrationV2Run, - "ordinal" | "freshContext" | "status" | "startedAt" | "restartContinuationOfRunId" - > - >, + projection: Pick, ): number { let boundary = 0; - for (const run of runs) { + for (const run of projection.runs) { if ( run.freshContext === true && run.status !== "queued" && !cancelledBeforeStart(run) && - run.ordinal > boundary + run.ordinal > boundary && + !isScheduledFreshRun(run, projection.turnItems) ) { boundary = run.ordinal; } @@ -1290,6 +1347,57 @@ function contextBoundaryOrdinal( return boundary; } +/** The provider thread a message continues: the active one, unless a fresh-session schedule run started it. */ +function conversationProviderThread( + projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "providerThreads" | "runs" | "turnItems" + >, +): OrchestrationV2ProviderThread | undefined { + const startedByScheduledFreshRun = (providerThread: OrchestrationV2ProviderThread) => + projection.runs.some( + (run) => + run.ordinal === providerThread.firstRunOrdinal && + isScheduledFreshRun(run, projection.turnItems), + ); + const active = projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ); + if (active === undefined || !startedByScheduledFreshRun(active)) return active; + const boundary = contextBoundaryOrdinal(projection); + return ( + projection.providerThreads + .filter( + (candidate) => + candidate.appThreadId === projection.thread.id && + candidate.ownerNodeId === null && + candidate.lastRunOrdinal !== null && + !providerThreadPredatesBoundary(candidate, boundary) && + !startedByScheduledFreshRun(candidate), + ) + .toSorted((left, right) => (right.lastRunOrdinal ?? 0) - (left.lastRunOrdinal ?? 0))[0] ?? + active + ); +} + +/** Wakes and fresh starts act on the active session; a message continues the conversation. */ +function dispatchProviderThread( + projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "providerThreads" | "runs" | "turnItems" + >, + message: Pick, + freshContext: boolean, +): OrchestrationV2ProviderThread | undefined { + return freshContext || + message.notification !== undefined || + message.delegatedCompletion !== undefined + ? projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ) + : conversationProviderThread(projection); +} + /** Threads with no started run (imported, or reserved by another queued run) never cross it. */ function providerThreadPredatesBoundary( providerThread: Pick, @@ -1325,10 +1433,13 @@ function uniqueSessions( } function rootProviderThreadsForProvider( - projection: Pick, + projection: Pick< + OrchestrationV2ThreadProjection, + "providerThreads" | "thread" | "runs" | "turnItems" + >, providerInstanceId: ModelSelection["instanceId"], ): ReadonlyArray { - const boundary = contextBoundaryOrdinal(projection.runs); + const boundary = contextBoundaryOrdinal(projection); return projection.providerThreads .filter( (providerThread) => @@ -1367,6 +1478,21 @@ function lastDeliveredRunForProviderThread( ); } +type DyingNativeSubagent = { + readonly projection: Pick; + readonly subagent: Pick< + Extract, + "subagentId" | "origin" | "childThreadId" | "providerInstanceId" + >; +}; + +const nativeSubagentRef = (subagent: OrchestrationV2Subagent): DyingNativeSubagent["subagent"] => ({ + subagentId: subagent.id, + origin: subagent.origin, + childThreadId: subagent.childThreadId, + providerInstanceId: subagent.providerInstanceId, +}); + const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(function* () { const checkpointService = yield* CheckpointServiceV2; const commandPolicy = yield* CommandPolicyV2; @@ -1416,7 +1542,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio /** The driver when this provider runs `/goal` natively, else undefined. */ const goalDriverFor = Effect.fn("Orchestrator.goalDriverFor")(function* ( - command: OrchestrationV2ServerCommand, + command: Pick, providerInstanceId: ProviderInstanceId, ) { const adapter = yield* providerAdapters.get(providerInstanceId).pipe(mapDispatchError(command)); @@ -1431,7 +1557,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio /** Unsupported goal controls can become new objectives in native providers. */ const ensureGoalCommandSupported = Effect.fn("Orchestrator.ensureGoalCommandSupported")( function* ( - command: OrchestrationV2ServerCommand, + command: Pick, message: { readonly text: string; readonly attachments: ReadonlyArray }, providerInstanceId: ProviderInstanceId, ) { @@ -1457,7 +1583,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const mapDispatchError = - (command: OrchestrationV2ServerCommand) => + (command: Pick) => (effect: Effect.Effect): Effect.Effect => effect.pipe( Effect.mapError( @@ -2378,6 +2504,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Queued run ${queuedRun.id} is missing projection state.`, }); } + // A scheduled delivery starts on the thread's current pick, so /goal must suit that provider. + yield* ensureGoalCommandSupported( + { commandId, type: "message.dispatch" }, + queuedMessage, + queuedRun.providerInstanceId, + ); const now = yield* DateTime.now; const targetAdapter = yield* providerAdapters.get(queuedRun.providerInstanceId).pipe( @@ -2390,12 +2522,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - const activeProviderThread = projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, - ); const followsCurrent = followsCurrentConversation(queuedMessage); const freshContext = queuedRun.freshContext === true; - const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + const activeProviderThread = dispatchProviderThread(projection, queuedMessage, freshContext); + const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection); // A run queued before a fresh run started is bound to the conversation that run left behind. const followsLatestContext = !freshContext && @@ -2406,7 +2536,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? activeProviderThread : rootProviderThreadsForProvider(projection, queuedRun.providerInstanceId)[0] : freshContext && - (storedProviderThread.firstRunOrdinal !== null || + // The reserved row belongs to the instance the run queued on, which may have changed. + (storedProviderThread.providerInstanceId !== queuedRun.providerInstanceId || + storedProviderThread.firstRunOrdinal !== null || storedProviderThread.nativeThreadRef !== null) ? undefined : storedProviderThread; @@ -3654,19 +3786,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.searchQuery, ) : null; - // Viewing a thread changes read state only. Loading its transcript (or - // bumping updatedAt) makes a routine read receipt scale with its history. + if (!movesForward && opens === null) return; + // Viewing a thread changes read state only. Loading its transcript, bumping + // updatedAt, or copying the thread into the event makes a read receipt scale with it. yield* emit( events, command, )({ - type: "thread.visited", + type: "thread.visit-recorded", threadId: command.threadId, providerInstanceId: thread.providerInstanceId, occurredAt, payload: { - ...thread, - ...(movesForward ? { lastVisitedAt: visitedAt.value } : {}), + lastVisitedAt: movesForward ? visitedAt.value : thread.lastVisitedAt, ...(opens ? { opens } : {}), }, }); @@ -4366,7 +4498,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) .map((message) => message.id), ); - if (queuedRuns.some((run) => !automaticMessageIds.has(run.userMessageId))) { + const userRuns = queuedRuns.filter((run) => !automaticMessageIds.has(run.userMessageId)); + // User work waiting behind a usage limit cannot start until the reset or the user acts. + const latest = latestExecutedRun(projection.runs); + const limitRecords = + userRuns.length === 0 || latest?.status !== "failed" + ? null + : yield* loadProjectionForCommand(command, ["turnItems", "providerSessions"], { + turnItemTypes: ["error"], + turnItemRunIds: [latest.id], + }); + const heldBehindLimit = + limitRecords !== null && + userRuns.every((run) => + usageLimitHoldsQueue({ ...limitRecords, runs: projection.runs }, run.providerInstanceId), + ); + if (userRuns.length > 0 && !heldBehindLimit) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -6135,7 +6282,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "provider_handoff", }), ); - const contextBoundary = contextBoundaryOrdinal(input.projection.runs); + const contextBoundary = contextBoundaryOrdinal(input.projection); restartHandoff = yield* contextHandoffService .prepareProviderHandoff({ threadId: input.command.threadId, @@ -6432,6 +6579,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); }); + // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent. + const refuseNativeSubagentMessage = Effect.fnUntraced(function* ( + command: Pick & { readonly threadId: ThreadId }, + ) { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (isProviderNativeSubagentThread(thread)) { + return yield* new OrchestratorSubagentThreadReadOnlyError({ + commandId: command.commandId, + threadId: command.threadId, + }); + } + return thread; + }); + const dispatchMessage = Effect.fn("Orchestrator.dispatchMessage")(function* ( command: Extract, events: Ref.Ref>, @@ -7099,8 +7266,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } - const activeProviderThread = projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, + const activeProviderThread = dispatchProviderThread( + projection, + { ...command, delegatedCompletion }, + freshContext, ); const activeRun = projection.runs.find(isActiveRun); // Messages after failed or stopped worktree setup must wait for a worktree, @@ -7384,6 +7553,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.scheduledTaskId !== undefined && command.modelSelection === undefined + ? { followsThreadSelection: true as const } + : {}), id: command.messageId, threadId: command.threadId, runId, @@ -7563,7 +7735,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } const ordinal = nextRunOrdinal(projection); const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); - const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection); const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); const latestHandoffRun = projection.runs.findLast(isHandoffSourceRun); const legacyImportItems = @@ -7796,6 +7968,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.scheduledTaskId !== undefined && command.modelSelection === undefined + ? { followsThreadSelection: true as const } + : {}), id: command.messageId, threadId: command.threadId, runId, @@ -9338,9 +9513,21 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (row) => row.id === message.runId, ); if (run === undefined || run.delegatedTaskId !== undefined) return; + // A wake continues the latest follow-up while it is open, as it continues the original task. + const latestFollowupId = + command.notification === undefined + ? undefined + : (yield* projectionStore + .getThreadRecords(command.threadId, ["runs"]) + .pipe(mapDispatchError(command))).runs + .filter((row) => row.id !== run.id && row.delegatedTaskId !== undefined) + .toSorted((a, b) => b.ordinal - a.ordinal)[0]?.delegatedTaskId; const parent = yield* projectionStore .getThreadRecords(parentThreadId, ["runs", "subagents", "providerTurns", "attempts"], { - subagentIds: child.forkedFrom?.type === "node" ? [child.forkedFrom.nodeId] : [], + subagentIds: [ + ...(child.forkedFrom?.type === "node" ? [child.forkedFrom.nodeId] : []), + ...(latestFollowupId === undefined ? [] : [latestFollowupId]), + ], }) .pipe(mapDispatchError(command)); const original = parent.subagents.find( @@ -9350,6 +9537,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio task.id === (child.forkedFrom?.type === "node" ? child.forkedFrom.nodeId : undefined), ); if (original === undefined || original.result === null) return; + const openFollowup = parent.subagents.find( + (task) => + task.id === latestFollowupId && + task.origin === "app_owned" && + task.childThreadId === child.id && + task.cancelledRunId === undefined && + task.result === null, + ); + if (openFollowup !== undefined) { + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: child.id, + runId: run.id, + occurredAt: message.createdAt, + payload: { ...run, delegatedTaskId: openFollowup.id }, + }); + return; + } const owner = followUpOwnerRun(parent.runs); if (owner === null || owner.rootNodeId === null) return; const now = message.createdAt; @@ -10910,10 +11118,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; const emitEvent = emit(events, command); + // A scheduled delivery steers on the thread's current pick, so /goal must suit that provider. + const modelSelection = queuedRunModelSelection(projection, queuedRun); const parsedGoalCommand = parseGoalCommand(queuedMessage); const goalCommand = parsedGoalCommand === null || - (yield* goalDriverFor(command, queuedRun.providerInstanceId)) === undefined + (yield* goalDriverFor(command, modelSelection.instanceId)) === undefined ? null : parsedGoalCommand; const target = projection.runs.find((run) => run.id === command.targetRunId); @@ -10927,7 +11137,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio isCompactionCommandMessage(targetMessage) ) { if (goalCommand !== null) { - yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); + yield* ensureGoalCommandSupported(command, queuedMessage, modelSelection.instanceId); } yield* emitEvent({ type: "run.updated", @@ -10960,7 +11170,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } if (goalCommand !== null) { - yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); + yield* ensureGoalCommandSupported(command, queuedMessage, modelSelection.instanceId); const goal = projection.providerThreads.find( (candidate) => candidate.id === target?.providerThreadId, )?.goal; @@ -11069,7 +11279,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, effects, projection, - modelSelection: queuedRun.modelSelection, + modelSelection, targetRunId: command.targetRunId, messageId: queuedMessage.id, text: queuedMessage.text, @@ -11188,7 +11398,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const projection = yield* loadProjectionForCommand( command, ["runs", "messages", "nodes", "attempts", "subagents", "turnItems"], - { turnItemTypes: [], messageRoles: ["user"] }, + // Cancelling reads the queued run's own message and root node, never a settled leaf. + { + turnItemTypes: [], + messageRoles: ["user"], + messageRunIds: [command.runId], + omitSettledLeafNodes: true, + }, ); const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId); if (queuedRun === undefined || queuedRun.status !== "queued") { @@ -11287,7 +11503,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* ensureGoalCommandSupported( command, { text: command.text, attachments: command.attachments ?? queuedMessage.attachments }, - queuedRun.providerInstanceId, + queuedRunModelSelection(projection, queuedRun).instanceId, ); const queuedTurnItem = projection.turnItems.find( (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, @@ -11708,6 +11924,78 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + // Ends each native subagent's record, its own thread's runless root turns, and the + // subagents it started there, which a provider can record on that thread rather than its parent's. + const endNativeSubagentTrees = Effect.fn("Orchestrator.endNativeSubagentTrees")( + function* (input: { + readonly command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "run.interrupt" | "thread.background-work.settle" | "thread.stop" } + >; + readonly events: Ref.Ref>; + readonly dying: ReadonlyArray; + readonly childThreadIds: Set; + readonly now: DateTime.Utc; + }) { + const allocateEventId = () => + idAllocator.allocate.event({ + threadId: input.command.threadId, + commandId: input.command.commandId, + }); + const dying = [...input.dying]; + const { childThreadIds } = input; + for (const { projection, subagent } of dying) { + const ended = yield* endOrphanedNativeSubagent({ + projection, + subagentId: subagent.subagentId, + status: "interrupted", + now: input.now, + emitted: yield* Ref.get(input.events), + allocateEventId, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...ended]); + const childThreadId = subagent.childThreadId; + if ( + subagent.origin !== "provider_native" || + childThreadId === null || + childThreadIds.has(childThreadId) + ) { + continue; + } + childThreadIds.add(childThreadId); + const child = yield* projectionStore + .getThreadRecords(childThreadId, ["nodes", "providerTurns", "subagents", "turnItems"], { + // Runless items are the subagent's; any run there is the user's own. + turnItemRunIds: [null], + turnItemStatuses: ["pending", "running", "waiting"], + }) + .pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + mapDispatchError(input.command), + ); + if (child === null) continue; + const childEnded = yield* endRunlessRootTurns({ + threadId: childThreadId, + providerInstanceId: subagent.providerInstanceId, + projection: child, + status: "interrupted", + now: input.now, + emitted: yield* Ref.get(input.events), + allocateEventId, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...childEnded]); + for (const nested of child.turnItems) { + if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); + } + for (const nested of child.subagents) { + if (nested.origin === "provider_native" && nested.runId === null) { + dying.push({ projection: child, subagent: nativeSubagentRef(nested) }); + } + } + } + }, + ); + const settleBackgroundWork = Effect.fn("Orchestrator.settleBackgroundWork")(function* (input: { readonly command: Extract< OrchestrationV2ServerCommand, @@ -11724,11 +12012,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }) { const emitEvent = emit(input.events, input.command); const runOrdinals = new Map(input.projection.runs.map((run) => [run.id, run.ordinal])); - const allocateEventId = () => - idAllocator.allocate.event({ - threadId: input.command.threadId, - commandId: input.command.commandId, - }); const childThreadIds = new Set(); const liveness = new Map(); const hasLiveSession = Effect.fn("Orchestrator.hasLiveSession")(function* ( @@ -11793,76 +12076,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); if (item.type !== "subagent") continue; - // A native subagent died with the process too: end its record, its own - // thread's runless root turn, and the subagents it started there, which - // a provider can record on that thread rather than this one. - const dying: Array<{ - readonly projection: Pick< - OrchestrationV2ThreadProjection, - "nodes" | "subagents" | "turnItems" - >; - readonly subagent: Pick< - Extract, - "subagentId" | "origin" | "childThreadId" | "providerInstanceId" - >; - }> = [{ projection: input.projection, subagent: item }]; - for (const { projection, subagent } of dying) { - const ended = yield* endOrphanedNativeSubagent({ - projection, - subagentId: subagent.subagentId, - status: "interrupted", - now: input.now, - emitted: yield* Ref.get(input.events), - allocateEventId, - }).pipe(mapDispatchError(input.command)); - yield* Ref.update(input.events, (events) => [...events, ...ended]); - const childThreadId = subagent.childThreadId; - if ( - subagent.origin !== "provider_native" || - childThreadId === null || - childThreadIds.has(childThreadId) - ) { - continue; - } - childThreadIds.add(childThreadId); - const child = yield* projectionStore - .getThreadRecords(childThreadId, ["nodes", "providerTurns", "subagents", "turnItems"], { - // Runless items are the subagent's; any run there is the user's own. - turnItemRunIds: [null], - turnItemStatuses: ["pending", "running", "waiting"], - }) - .pipe( - Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), - mapDispatchError(input.command), - ); - if (child === null) continue; - const childEnded = yield* endRunlessRootTurns({ - threadId: childThreadId, - providerInstanceId: subagent.providerInstanceId, - projection: child, - status: "interrupted", - now: input.now, - emitted: yield* Ref.get(input.events), - allocateEventId, - }).pipe(mapDispatchError(input.command)); - yield* Ref.update(input.events, (events) => [...events, ...childEnded]); - for (const nested of child.turnItems) { - if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); - } - for (const nested of child.subagents) { - if (nested.origin === "provider_native" && nested.runId === null) { - dying.push({ - projection: child, - subagent: { - subagentId: nested.id, - origin: nested.origin, - childThreadId: nested.childThreadId, - providerInstanceId: nested.providerInstanceId, - }, - }); - } - } - } + // A native subagent died with the process too. + yield* endNativeSubagentTrees({ + command: input.command, + events: input.events, + dying: [{ projection: input.projection, subagent: item }], + childThreadIds, + now: input.now, + }); } for (const providerThread of input.projection.providerThreads) { if ((providerThread.pendingBackgroundTasks?.length ?? 0) === 0) continue; @@ -13338,6 +13559,36 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio for (const run of reached.values()) { yield* markStoppedRun({ command, events, thread: projection.thread, run, now }); } + // Record and item are written together, so an active record without a live item lost its end. + const staleNative = (target === undefined ? projection.subagents : []).filter( + (subagent) => + subagent.origin === "provider_native" && + subagent.runId !== null && + isOrchestrationV2WorkActive(subagent.status) && + !projection.turnItems.some( + (item) => item.type === "subagent" && item.subagentId === subagent.id, + ), + ); + if (staleNative.length > 0) { + const { nodes } = yield* projectionStore + .getThreadRecords(command.threadId, ["nodes"]) + .pipe(mapDispatchError(command)); + const threadProjection = { + nodes, + subagents: projection.subagents, + turnItems: projection.turnItems, + }; + yield* endNativeSubagentTrees({ + command, + events, + dying: staleNative.map((subagent) => ({ + projection: threadProjection, + subagent: nativeSubagentRef(subagent), + })), + childThreadIds: new Set(), + now, + }); + } yield* holdStoppedThread({ command, events, projection, cohortRunIds: [], now }); yield* cancelIdleOrphans({ command, events, projection }); yield* stopDelegatedTasks; @@ -13855,13 +14106,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) { return; } - const taskRuns = childControls.runs.filter((run) => run.delegatedTaskId === options.task.id); + const taskRuns = delegatedTaskRuns(childControls, options.task.id); const childRun = taskRuns.length === 0 ? options.task.id === forkedFrom.nodeId ? options.resultRun : undefined - : delegatedTaskResultRun(taskRuns, options.task.id); + : delegatedTaskResultRun(taskRuns); if (childRun === undefined) return; let outcome = runOutcome(childRun); let terminalStatus = delegatedTaskTerminalStatus(outcome); @@ -13921,6 +14172,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); return; } + // A follow-up ends with the work it awaits, as the original task does. A cancelled task + // does not wait for the background work its cancel is stopping. + if ( + delegatedTaskAwaitsWork( + taskRuns, + options.task.cancelledRunId === undefined + ? childControls + : { subagents: childControls.subagents, providerThreads: [] }, + ) + ) + return; if ( yield* childAwaitsRestartContinuation( taskRuns.length === 0 ? childControls.runs : taskRuns, @@ -14567,7 +14829,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.active.reorder": case "thread.tag.set": case "thread.mark-unread": - case "thread.metadata.update": case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": @@ -14580,6 +14841,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider.switch": yield* dispatchThreadMutation(command, events, effects); break; + case "thread.metadata.update": { + const continuation = metadataUpdateContinuation(command); + if (continuation !== undefined) yield* refuseNativeSubagentMessage(command); + yield* dispatchThreadMutation(command, events, effects); + if (continuation !== undefined) { + cancelUnsettledEffects = yield* dispatchMessage(continuation, events, effects); + yield* recordSubagentFollowup(continuation, events).pipe(mapDispatchError(command)); + } + break; + } case "thread.pull-request-watch.checks-reopen": yield* dispatchThreadMutation(command, events, effects); break; @@ -14600,20 +14871,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* dispatchThreadProjectAdoptFolder(command, events); break; case "message.dispatch": { - // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent. - const thread = yield* projectionStore - .getThread(command.threadId) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), - ); - if (isProviderNativeSubagentThread(thread)) { - return yield* new OrchestratorSubagentThreadReadOnlyError({ - commandId: command.commandId, - threadId: command.threadId, - }); - } + const thread = yield* refuseNativeSubagentMessage(command); const guestSend = command.guestMessageId === undefined ? undefined @@ -14940,6 +15198,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio planned.events.length > 0 || isUnobservedAcknowledgement(command) || command.type === "thread.background-work.settle" || + // A stale visit changes no read state. + command.type === "thread.visit" || // A read can answer a watch again before a push's reopen lands; nothing is left to clear. command.type === "thread.pull-request-watch.checks-reopen" || command.type === "thread.interrupt.unresponsive" || @@ -15194,11 +15454,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); // Taken before the lineage read, so a message whose read yields keeps its // place ahead of later messages to the same thread. - if (command.type === "message.dispatch") + const message = + command.type === "thread.metadata.update" ? metadataUpdateContinuation(command) : command; + if (message?.type === "message.dispatch") return yield* messageIntake.withLock( - command.threadId, - mayStartSubagentFollowup(command) - ? withSubagentParentLock(command.threadId, dispatch) + message.threadId, + mayStartSubagentFollowup(message) + ? withSubagentParentLock(message.threadId, dispatch) : dispatch, ); // Answering a message-mode question can also start a follow-up. @@ -15310,7 +15572,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (stored.event.type !== "provider-turn.updated") return; const threadId = stored.event.threadId; const turn = stored.event.payload; - const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"]); + const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"], { + runStatuses: ["queued", "running"], + }); const run = projection.runs.find( (candidate) => candidate.activeAttemptId === turn.runAttemptId && candidate.status === "running", @@ -15498,9 +15762,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio { messageRoles: ["user"] }, ); if (taskId !== undefined) { - const runs = child.runs.filter((run) => run.delegatedTaskId === taskId); - const run = delegatedTaskResultRun(runs, taskId); - if (run === undefined || delegatedTaskTerminalStatus(run.status) === null) return true; + const runs = delegatedTaskRuns(child, taskId); + const run = delegatedTaskResultRun(runs); + if ( + run === undefined || + delegatedTaskTerminalStatus(run.status) === null || + delegatedTaskAwaitsWork(runs, child) + ) + return true; return yield* childAwaitsRestartContinuation(runs, run); } const progress = delegatedTaskProgress(child); @@ -15593,7 +15862,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? child.thread.forkedFrom?.type === "node" && child.thread.forkedFrom.nodeId === input.taskId && progressAfterTurn(child).state !== "result_available" - : delegatedTaskTerminalStatus(runOutcome(followUpRun)) === null; + : delegatedTaskTerminalStatus(runOutcome(followUpRun)) === null || + delegatedTaskAwaitsWork(delegatedTaskRuns(child, input.taskId), child); yield* threadDispatch.withLock( command.threadId, Effect.gen(function* () { @@ -15719,6 +15989,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .getMessageCount(threadId) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }), + getMessagesById: Effect.fn("OrchestratorV2.getMessagesById")(function (threadId, messageIds) { + return projectionStore + .getMessagesById(threadId, messageIds) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), getTranscriptByteFloor: Effect.fn("OrchestratorV2.getTranscriptByteFloor")( function (threadId, limit) { return projectionStore @@ -15799,6 +16074,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio listActiveShellIndex: Effect.fn("OrchestratorV2.listActiveShellIndex")(function () { return projectionStore.listActiveShellIndex().pipe(Effect.mapError(shellProjectionError)); }), + listActiveThreads: Effect.fn("OrchestratorV2.listActiveThreads")(function (projectId) { + return projectionStore + .listActiveThreads(projectId) + .pipe(Effect.mapError(shellProjectionError)); + }), listOlderSettledThreads: Effect.fn("OrchestratorV2.listOlderSettledThreads")(function (query) { return projectionStore .listOlderSettledThreads(query) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3b34434017e9..8b967f648165 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2807,11 +2807,25 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => assert.deepEqual([...(yield* family(id("missing")))], []); // Includes another root's idle child on the same path, which a shell may leave out. - const onWorktree = yield* store.getShellSnapshot({ location: "active", worktreePath }); + const { statements, tracer } = traceSqlStatements(); + const onWorktree = yield* store + .getShellSnapshot({ location: "active", worktreePath }) + .pipe(Effect.withTracer(tracer)); assert.sameMembers( onWorktree.threads.map((thread) => thread.id), [id("root"), id("child"), id("other-child")], ); + // A path names a few threads; scanning every thread parses every payload. + const sql = yield* SqlClient.SqlClient; + const threadQuery = statements.find((statement) => + statement.includes("AS latest_run_stop_requested"), + ); + assert.isDefined(threadQuery); + const threadPlan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${threadQuery}`, + [], + )).map((row) => row.detail); + assert.notInclude(threadPlan, "SCAN t", threadPlan.join("\n")); // A fork keeps its deleted source's root, so the deleted root still names the live tree. yield* store.apply({ id: EventId.make("event:family:root-deleted"), @@ -4319,9 +4333,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { workCursor = page.nextCursor; } assert.strictEqual(workIds.size, 999); - for (const toolName of ["t3-code.delegate_task", "mcp__t3_code__delegate_task"]) { + for (const [toolName, output] of [ + ["t3-code.delegate_task", null], + ["mcp__t3_code__delegate_task", null], + ["mcp__t3-code__send_to_user", null], + // OpenCode Code Mode reports a nested send_to_user call as one `execute` item. + ["execute", JSON.stringify({ sentMessage: "Draft" })], + ] as const) { yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool', - payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', ${toolName}) + payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', ${toolName}, + '$.output', ${output}) WHERE thread_id = ${threadId} AND ordinal = 2`; const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: sqlPageLimit, @@ -6629,6 +6650,10 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.isNotEmpty(countQueries); for (const countQuery of countQueries) { for (const detail of yield* planOf(countQuery)) { + // A thread's counts read only each run's status, never its row and payload. + if (/^SEARCH (r|run) /.test(detail)) { + assert.include(detail, "COVERING INDEX", detail); + } if (detail.includes("orchestration_v2_projection_turn_items")) { // Only held input is read from the sparse index; transcript history stays unread. assert.include( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 841cd0a0b5b7..3bdbbffc0373 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,5 +1,5 @@ import type { ProjectThreadAwarenessV2Input } from "@t3tools/shared/agentAwareness"; -import { pendingGuestMessageCount } from "@t3tools/contracts"; +import { applyOrchestrationV2ThreadVisit, pendingGuestMessageCount } from "@t3tools/contracts"; import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; @@ -467,6 +467,19 @@ export interface ActiveShellIndexEntry extends Pick< readonly hasRemoteTasks: boolean; } +/** What thread listings filter and order an active thread by, without building its shell. */ +export type ActiveThreadListEntry = Pick< + OrchestrationV2ThreadShell, + | "id" + | "projectId" + | "title" + | "lineage" + | "settledOverride" + | "settledAt" + | "snoozedUntil" + | "updatedAt" +>; + export interface OlderSettledThreadsQuery { readonly before: OrchestrationV2OlderSettledCursor; readonly projectIds?: ReadonlyArray | undefined; @@ -567,6 +580,10 @@ export interface ProjectionStoreV2Shape { ReadonlyArray, ProjectionStoreV2Error >; + /** Every active thread, or one project's. */ + readonly listActiveThreads: ( + projectId?: ProjectId, + ) => Effect.Effect, ProjectionStoreV2Error>; /** Older settled thread ids below a cursor, newest settle first, and per-project counts. */ readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, @@ -927,6 +944,11 @@ export function applyToProjection( ...projection, thread: event.payload, }; + case "thread.visit-recorded": + return { + ...projection, + thread: applyOrchestrationV2ThreadVisit(projection.thread, event.payload), + }; case "run.created": case "run.updated": return withLocalVisibleTurnItems({ @@ -1145,6 +1167,21 @@ const encodeTimelineSources = Schema.encodeEffect( Schema.Array(Schema.Struct({ threadId: Schema.String, id: Schema.NullOr(Schema.String) })), ), ); +const shellIndexLineage = (row: { + readonly relationship_to_parent: string | null; + readonly parent_thread_id: string | null; + readonly root_thread_id: string; + readonly side_chat: number | null; +}): OrchestrationV2ThreadShell["lineage"] => ({ + parentThreadId: row.parent_thread_id === null ? null : ThreadId.make(row.parent_thread_id), + relationshipToParent: + row.relationship_to_parent === "fork" || row.relationship_to_parent === "subagent" + ? row.relationship_to_parent + : null, + rootThreadId: ThreadId.make(row.root_thread_id), + ...(row.side_chat === null ? {} : { sideChat: row.side_chat === 1 }), +}); + const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); // Empty filters still scan SQLite rows; short-circuit them, including turnItemTypes: []. @@ -1212,6 +1249,7 @@ const PullRequestThread = OrchestrationV2AppThreadJsonSchema.mapFields( projectId, lineage, createdAt, + updatedAt, branch, worktreePath, archivedAt, @@ -1225,6 +1263,7 @@ const PullRequestThread = OrchestrationV2AppThreadJsonSchema.mapFields( projectId, lineage, createdAt, + updatedAt, branch, worktreePath, archivedAt, @@ -2123,7 +2162,9 @@ export const layer: Layer.Layer = const inlineWorkOutput = sql`item.type = 'dynamic_tool' AND ( item.payload_json LIKE '%htmlRender%' OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} OR item.payload_json LIKE '%"viewedImagePath"%' - OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`; + OR item.payload_json LIKE '%"toolName":"%delegate_task"%' + OR item.payload_json LIKE '%"toolName":"%send_to_user"%' + OR item.payload_json LIKE '%sentMessage%')`; // SQLite uses the partial conversation index of migration 082 only for this exact predicate. const CONVERSATION_INDEX_EXCLUDED_TYPES = sql.literal( @@ -2242,6 +2283,15 @@ export const layer: Layer.Layer = }); break; } + case "thread.visit-recorded": { + const thread = yield* getThread(event.threadId); + yield* apply({ + ...event, + type: "thread.visited", + payload: applyOrchestrationV2ThreadVisit(thread, event.payload), + }); + break; + } case "thread.created": case "thread.archived": case "thread.unarchived": @@ -3074,6 +3124,7 @@ export const layer: Layer.Layer = event.type !== "thread.active-reordered" && event.type !== "thread.tags-updated" && event.type !== "thread.visited" && + event.type !== "thread.visit-recorded" && event.type !== "thread.marked-unread" && event.type !== "thread.metadata-updated" && event.type !== "thread.pull-request-synced" && @@ -7666,12 +7717,11 @@ export const layer: Layer.Layer = ( SELECT item.payload_json FROM orchestration_v2_projection_turn_items item - INNER JOIN orchestration_v2_projection_runs r ON r.run_id = item.run_id - WHERE r.run_id = presented.run_id - AND r.status = 'failed' + WHERE presented.status = 'failed' AND item.thread_id = t.thread_id + AND item.run_id = presented.run_id AND item.type = 'error' AND item.status = 'failed' - AND item.node_id IS json_extract(r.payload_json, '$.rootNodeId') + AND item.node_id IS json_extract(presented.payload_json, '$.rootNodeId') ORDER BY item.updated_at DESC, item.ordinal DESC, item.turn_item_id DESC LIMIT 1 ) AS terminal_failure_payload_json, @@ -7789,9 +7839,9 @@ export const layer: Layer.Layer = projectId === undefined ? sql`` : sql` AND t.project_id = ${projectId}` }${ location === "active" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` + ? sql` AND t.archived_at IS NULL` : location === "archive" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NOT NULL` + ? sql` AND t.archived_at IS NOT NULL` : sql`` }${ unsettledOnly @@ -7800,7 +7850,8 @@ export const layer: Layer.Layer = }${ familyOf === undefined ? sql`` - : sql` AND json_extract(t.payload_json, '$.lineage.rootThreadId') = ( + : sql` AND CASE WHEN json_valid(t.payload_json) + THEN json_extract(t.payload_json, '$.lineage.rootThreadId') END = ( SELECT json_extract(member.payload_json, '$.lineage.rootThreadId') FROM orchestration_v2_projection_threads member WHERE member.thread_id = ${familyOf} @@ -7808,7 +7859,8 @@ export const layer: Layer.Layer = }${ worktreePath === undefined ? sql`` - : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` + : sql` AND CASE WHEN json_valid(t.payload_json) + THEN json_extract(t.payload_json, '$.worktreePath') END = ${worktreePath}` }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -7836,6 +7888,7 @@ export const layer: Layer.Layer = WHEN EXISTS ( SELECT 1 FROM orchestration_v2_projection_runs AS run + INDEXED BY orchestration_v2_projection_runs_run_status_idx WHERE run.run_id = per_run.run_key AND ( run.status IN ('queued', 'cancelled') @@ -7882,7 +7935,10 @@ export const layer: Layer.Layer = SUM(CASE WHEN counts.run_key = '' THEN counts.item_count ELSE 0 END) AS runless_item_count FROM orchestration_v2_projection_turn_item_counts counts - LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_key + -- The primary key would load each run's whole row just for its status. + LEFT JOIN orchestration_v2_projection_runs r + INDEXED BY orchestration_v2_projection_runs_run_status_idx + ON r.run_id = counts.run_key WHERE counts.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY counts.thread_id `; @@ -8742,22 +8798,13 @@ export const layer: Layer.Layer = ) AS has_remote_tasks FROM orchestration_v2_projection_threads t WHERE t.deleted_at IS NULL - AND json_extract(t.payload_json, '$.archivedAt') IS NULL + AND t.archived_at IS NULL ORDER BY t.updated_at ASC, t.thread_id ASC `; return rows.map((row): ActiveShellIndexEntry => ({ id: ThreadId.make(row.thread_id), projectId: ProjectId.make(row.project_id), - lineage: { - parentThreadId: - row.parent_thread_id === null ? null : ThreadId.make(row.parent_thread_id), - relationshipToParent: - row.relationship_to_parent === "fork" || row.relationship_to_parent === "subagent" - ? row.relationship_to_parent - : null, - rootThreadId: ThreadId.make(row.root_thread_id), - ...(row.side_chat === null ? {} : { sideChat: row.side_chat === 1 }), - }, + lineage: shellIndexLineage(row), settledOverride: row.settled_override === "settled" || row.settled_override === "active" ? row.settled_override @@ -8770,6 +8817,53 @@ export const layer: Layer.Layer = })); }, Effect.mapError(shellSnapshotReadError)); + const listActiveThreads = Effect.fn("ProjectionStore.listActiveThreads")(function* ( + projectId?: ProjectId, + ) { + const rows = yield* sql<{ + readonly thread_id: string; + readonly project_id: string; + readonly title: string; + readonly updated_at: string; + readonly relationship_to_parent: string | null; + readonly parent_thread_id: string | null; + readonly root_thread_id: string; + readonly side_chat: number | null; + readonly settled_override: string | null; + readonly settled_at: string | null; + readonly snoozed_until: string | null; + }>` + SELECT + t.thread_id, + t.project_id, + t.title, + t.updated_at, + json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, + json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, + json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.lineage.sideChat') AS side_chat, + json_extract(t.payload_json, '$.settledOverride') AS settled_override, + json_extract(t.payload_json, '$.settledAt') AS settled_at, + json_extract(t.payload_json, '$.snoozedUntil') AS snoozed_until + FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL + AND t.archived_at IS NULL${projectId === undefined ? sql`` : sql` AND t.project_id = ${projectId}`} + `; + return rows.map((row): ActiveThreadListEntry => ({ + id: ThreadId.make(row.thread_id), + projectId: ProjectId.make(row.project_id), + title: row.title, + lineage: shellIndexLineage(row), + settledOverride: + row.settled_override === "settled" || row.settled_override === "active" + ? row.settled_override + : null, + settledAt: row.settled_at === null ? null : DateTime.makeUnsafe(row.settled_at), + snoozedUntil: row.snoozed_until === null ? null : DateTime.makeUnsafe(row.snoozed_until), + updatedAt: DateTime.makeUnsafe(row.updated_at), + })); + }, Effect.mapError(shellSnapshotReadError)); + const getThreadLinkMetadata = Effect.fn("ProjectionStore.getThreadLinkMetadata")(function* ( threadIds: ReadonlyArray, ) { @@ -8959,6 +9053,7 @@ export const layer: Layer.Layer = listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), countThreads: readOnReader(countThreads), listActiveShellIndex: readOnReader(listActiveShellIndex), + listActiveThreads: readOnReader(listActiveThreads), readShellSnapshot: readOnReader(readShellSnapshot), listOlderSettledThreads: readOnReader(listOlderSettledThreads), getThreadShell: readOnReader(getThreadShell), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index f69444d23571..ebe5fba1efb0 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -49,8 +49,10 @@ import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import type { ServerProvider } from "@t3tools/contracts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, @@ -230,7 +232,7 @@ function threadCreatedEvent( const layer = it.layer(layerTest); -it.effect("refreshes the failing instance snapshot after a committed usage_limit terminal", () => +it.effect("takes a hub's reset for an unreset usage_limit, then refreshes the snapshot", () => Effect.gen(function* () { const refreshed = yield* Deferred.make(); const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe( @@ -261,7 +263,9 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }, snapshot: { refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid), - getSnapshot: Effect.die("unused"), + getSnapshot: Effect.succeed({ + auth: { status: "authenticated", endpoint: "http://hub.test:8317" }, + } as ServerProvider), refresh: Effect.die("unused"), resolveMaintenance: () => Effect.die("unused"), applyUsageLimits: () => Effect.die("unused"), @@ -269,6 +273,7 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }, }; const lookups: ProviderInstanceId[] = []; + const hubLookups: Array<[string, string, string | null]> = []; const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ getInstance: (id) => Effect.sync(() => { @@ -317,10 +322,29 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }); assert.equal(stored.length, 1); if (failureClass === "validation_error") assert.deepEqual(lookups, []); + else { + const event = stored[0]?.event; + assert.equal( + event?.type === "turn-item.updated" && event.payload.type === "error" + ? event.payload.failure.resetAt + : undefined, + "2026-11-13T18:00:00.000Z", + ); + } } - assert.deepEqual(lookups, [instance.instanceId]); + assert.deepEqual(lookups, [instance.instanceId, instance.instanceId]); + assert.deepEqual(hubLookups, [["cursor", "http://hub.test:8317", "gpt-5.4"]]); yield* Deferred.await(refreshed); - }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }).pipe( + Effect.provide(makeLayerTest(layerTestEventSink, instances)), + Effect.provideService(UsageLimitResetFallback, { + resetAt: (driver, endpoint, model) => + Effect.sync(() => { + hubLookups.push([driver, endpoint, model]); + return "2026-11-13T18:00:00.000Z"; + }), + }), + ); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 6fd6e7bc3129..fddffcd6ee19 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -32,6 +32,7 @@ import { } from "../telemetry/ProviderUsageAnalytics.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -254,6 +255,7 @@ export const layer: Layer.Layer< const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const usageLimitResets = yield* UsageLimitResetFallback; const scope = yield* Effect.scope; const completedTurnAnalytics = new Set(); @@ -656,9 +658,39 @@ export const layer: Layer.Layer< return dismissed; } const occurredAt = yield* DateTime.now; - const failure = (yield* agentScope.oomKilled(input.threadId)) + const reported = (yield* agentScope.oomKilled(input.threadId)) ? { ...input.event.failure, message: "Killed: out of memory.", code: "oom_kill" } : input.event.failure; + // A proxied CLI can drop the reset time; the hub still knows when its pool refills. + const endpoint = + reported.class === "usage_limit" && reported.resetAt == null + ? (yield* Effect.flatMap( + instances.getInstance(input.providerInstanceId), + (instance) => instance?.snapshot.getSnapshot ?? Effect.undefined, + ))?.auth.endpoint + : undefined; + const hubResetAt = + endpoint === undefined + ? null + : yield* Effect.gen(function* () { + // The failing run's model, which may differ from a selection changed mid-turn. + const { runs } = + input.runId === undefined + ? { runs: [] } + : yield* projections.getThreadRecords(input.threadId, ["runs"], { + runIds: [input.runId], + }); + return ( + runs[0]?.modelSelection ?? + (yield* projections.getThread(input.threadId)).modelSelection + ).model; + }).pipe( + Effect.orElseSucceed(() => null), + Effect.flatMap((model) => + usageLimitResets.resetAt(input.event.driver, endpoint, model), + ), + ); + const failure = hubResetAt === null ? reported : { ...reported, resetAt: hubResetAt }; return [ ...dismissed, yield* makeDomainEvent(input, { diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 911c4dd03389..6c5cc4ce8a6e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3426,10 +3426,19 @@ it.effect.each(["detach", "stream failure", "provider stop", "early close"] as c ); yield* interrupts(1); yield* Deferred.succeed(resume, undefined); + const consumed = yield* Fiber.join(consumer); assert.equal( - Exit.isFailure(yield* Fiber.join(consumer)), + Exit.isFailure(consumed), release !== "provider stop" && release !== "early close", ); + // regression: #22971 only a detach names its thread, so a handoff run can settle on it. + assert.equal( + Exit.isFailure(consumed) + ? (Cause.squash(consumed.cause) as ProviderAdapter.ProviderAdapterEventStreamError) + .detachedThreadId + : undefined, + release === "detach" ? threadId : undefined, + ); yield* interrupts(2); assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 3cc52688ac36..8ef6d5e1179c 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -663,10 +663,12 @@ export const layerWithOptions = ( detail: string, afterDrain: Effect.Effect, cause?: Cause.Cause, + detachedThreadId?: ThreadId, ) { const error = new ProviderAdapter.ProviderAdapterEventStreamError({ driver: entry.runtime.driver, providerSessionId: entry.runtime.providerSessionId, + ...(detachedThreadId === undefined ? {} : { detachedThreadId }), cause: detail, }); const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); @@ -1353,6 +1355,7 @@ export const layerWithOptions = ( input.detail ?? `Provider session released: ${input.reason}.`, afterDrain, input.eventStreamCause, + input.detachedThreadId, ); } const closeExit = yield* closeScopeWithin(entry.scope, { diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 3ede421ad72a..c3d1096cb3c9 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -218,6 +218,7 @@ it.effect( listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), countThreads: () => Effect.die("unused countThreads"), listActiveShellIndex: () => Effect.die("unused listActiveShellIndex"), + listActiveThreads: () => Effect.die("unused listActiveThreads"), listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 62f095b6fd91..dcc40b0b44ac 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -40,6 +40,10 @@ import { withWorkspaceLease, } from "../workspace/workspaceLease.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import { + layerThreadWorktreeRestore, + ThreadWorktreeRestore, +} from "../workspace/restoreMissingWorktree.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { layerRealGit } from "./ProviderTurnStartService.testkit.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -272,6 +276,101 @@ it.each([ expect(harness.createWorktree).toHaveBeenCalledTimes(input.recreated ? 1 : 0); }); +const restoreLeaseExists = vi.fn(() => Effect.succeed(false)); +const restoreLeaseCreateWorktree = vi.fn(() => Effect.succeed({} as never)); +const layerRestoreLeaseTest = layerThreadWorktreeRestore.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, { + exists: restoreLeaseExists, + realPath: (target: string) => Effect.succeed(normalize(target)), + } as never), + layerWorktreeLocation(), + Layer.mock(GitWorkflow.GitWorkflowService)({ + pruneWorktrees: () => Effect.void, + createWorktree: restoreLeaseCreateWorktree, + listLocalBranchNames: () => Effect.succeed(["feature/restore"]), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeedSome({ workspaceRoot: "/tmp/provider-turn-start-project" } as never), + }), + ), + ), +); + +effectIt.effect("restores a missing worktree before a send only under the workspace lease", () => + Effect.gen(function* () { + const worktreePath = `${managedWorktreesDir}/project/feature-restore`; + const { restore } = yield* ThreadWorktreeRestore; + yield* withWorkspaceLease( + worktreePath, + Effect.gen(function* () { + const restoring = yield* restore({ + id: ThreadId.make("thread_send_restore_lease"), + projectId: ProjectId.make("project_send_restore_lease"), + worktreePath, + branch: "feature/restore", + }).pipe(Effect.forkChild({ startImmediately: true })); + expect(restoreLeaseExists).not.toHaveBeenCalled(); + return restoring; + }), + ).pipe(Effect.flatMap(Fiber.join)); + expect(restoreLeaseCreateWorktree).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(layerRestoreLeaseTest)), +); + +it.each([ + { label: "directly in the scratch root", folder: "/tmp/t3-home/scratch/notes", recreated: true }, + { label: "below another folder", folder: "/tmp/t3-home/scratch/notes/deep", recreated: false }, + { + label: "in another project", + folder: "/tmp/elsewhere/notes", + workspaceRoot: "/tmp/elsewhere", + recreated: false, + }, +])("recreates a pruned No project folder only $label", async (input) => { + const makeDirectory = vi.fn(() => Effect.void); + await Effect.gen(function* () { + const { restore } = yield* ThreadWorktreeRestore; + yield* restore({ + id: ThreadId.make("thread_scratch_restore"), + projectId: ProjectId.make("project_scratch_restore"), + worktreePath: input.folder, + branch: null, + }); + }).pipe( + Effect.provide( + layerThreadWorktreeRestore.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, { + exists: () => Effect.succeed(false), + realPath: (target: string) => Effect.succeed(target), + makeDirectory, + } as never), + Path.layer, + Layer.mock(ServerConfig.ServerConfig)({ + baseDir: "/tmp/t3-home", + worktreesDir: managedWorktreesDir, + } as ServerConfig.ServerConfig["Service"]), + ServerSettings.layerTest(), + Layer.mock(GitWorkflow.GitWorkflowService)({}), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeedSome({ + workspaceRoot: input.workspaceRoot ?? "/tmp/t3-home/scratch", + } as never), + }), + ), + ), + ), + ), + Effect.runPromise, + ); + expect(makeDirectory).toHaveBeenCalledTimes(input.recreated ? 1 : 0); +}); + function makeLocalCommandHarness(input: { readonly text: string; readonly nativeGoals?: boolean; @@ -1027,7 +1126,10 @@ function makeLocalCommandHarness(input: { ), }), Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }), - Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), + Layer.mock(RunExecutionService.RunExecutionServiceV2)({ + startRootRun, + forkBaseline: () => Effect.succeed(Effect.void), + }), Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, { runStarted: () => Effect.void, turnCompleted: () => Effect.void, @@ -1127,6 +1229,8 @@ effectIt.effect( const expectedTips = { retained: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, + // A failed removal can leave the folder behind with only dependencies inside. + leftover: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, deleted: { tip: projectTip, file: "project.txt", upstream: null, base: "project-current" }, pushed: { tip: threadTip, @@ -1171,7 +1275,12 @@ effectIt.effect( yield* git(workspaceRoot, ["remote", "remove", "origin"]); } yield* fileSystem.remove(worktreePath, { recursive: true }); - if (scenario !== "retained") { + if (scenario === "leftover") { + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { + recursive: true, + }); + } + if (scenario !== "retained" && scenario !== "leftover") { yield* git(workspaceRoot, ["worktree", "prune"]); yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]); } @@ -1220,6 +1329,27 @@ effectIt.effect( }).pipe(Effect.scoped, Effect.provide(layerRealGit)), ); +effectIt.effect("leaves a managed worktree folder without .git that holds user files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-leftover-" }); + const worktreePath = path.join(worktreesDir, "project", "restore"); + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreePath, "notes.md"), "keep me"); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + worktreesDir, + fileSystem, + failReadsAfterRunning: true, + }); + yield* harness.start; + expect(harness.createWorktree).not.toHaveBeenCalled(); + expect(yield* fileSystem.readFileString(path.join(worktreePath, "notes.md"))).toBe("keep me"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + effectIt.effect( "preserves recreation interrupts and retries, then fails the final attempt before provider start", () => @@ -1422,6 +1552,50 @@ effectIt.effect.each([ }), ); +effectIt.effect("leaves delegated children to their own runs when the parent starts a turn", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + previousTurn: { runStatus: "completed", turnStatus: "completed" }, + }); + const projection = harness.projection(); + const completedChild = (origin: "provider_native" | "app_owned") => ({ + id: NodeId.make(`node:${origin}`), + threadId: projection.thread.id, + runId: projection.runs[0]!.id, + parentNodeId: NodeId.make(`node:${origin}:parent`), + origin, + createdBy: "agent" as const, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerThreadId: ProviderThreadId.make(`provider-thread:${origin}`), + childThreadId: ThreadId.make(`thread:${origin}`), + nativeTaskRef: null, + prompt: "work", + title: null, + model: null, + status: "completed" as const, + result: "done", + startedAt: null, + completedAt: null, + updatedAt: projection.updatedAt, + }); + harness.setProjection({ + ...projection, + subagents: [completedChild("provider_native"), completedChild("app_owned")], + }); + + yield* harness.start; + + // A delegated child thread has its own run on the shared session; a parent run that also + // owns it ingests every one of the child's later items a second time. + expect(harness.startRootRun.mock.calls[0]?.[0]).toMatchObject({ + relatedThreadIds: [ThreadId.make("thread:provider_native")], + relatedProviderThreadIds: [ProviderThreadId.make("provider-thread:provider_native")], + }); + }), +); + effectIt.effect("does not start another turn when abandoned-turn cleanup fails", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 8f01d117e173..7fe02946f2c8 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -697,6 +697,12 @@ export const layer: Layer.Layer< }); if (!repaired.committed) return; } + // Opening a session does not prompt the provider, so the workspace is still unchanged. + const baseline = yield* runExecution.forkBaseline({ + appThread: projection.thread, + run, + checkpointScope, + }); const sessionResult = yield* Effect.result( providerSessions.open({ threadId: projection.thread.id, @@ -1586,8 +1592,11 @@ export const layer: Layer.Layer< if (!runningWrite.committed) { return; } - const routableSubagents = projection.subagents.filter((subagent) => - RunExecutionService.canRouteRelatedSubagent(subagent.status), + // Delegated (app_owned) children run their own runs; routing them here ingests their items twice. + const routableSubagents = projection.subagents.filter( + (subagent) => + subagent.origin === "provider_native" && + RunExecutionService.canRouteRelatedSubagent(subagent.status), ); const persistHandoff = Effect.fn("ProviderTurnStartService.persist")(function* ( handoff: OrchestrationV2ContextHandoff, @@ -1639,12 +1648,11 @@ export const layer: Layer.Layer< .filter((part) => part !== "") .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; + const text = goalCommand ? userText : withInlineContext(context, userText); yield* start({ ...(noteContinuation ? promptedInput : turnInput), - message: { - ...turnInput.message, - text: goalCommand ? userText : withInlineContext(context, userText), - }, + message: { ...turnInput.message, text }, + ...(text === userText ? {} : { inlineContextLength: text.length - userText.length }), }).pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. Effect.tapError((error) => @@ -1717,6 +1725,7 @@ export const layer: Layer.Layer< providerThread: runningProviderThread, attempt: runningAttempt, attemptId: attempt.id, + baseline, loadInheritedBackgroundTurnItems: runControls.loadInheritedBackgroundTurnItems, relatedThreadIds: routableSubagents.flatMap((subagent) => subagent.childThreadId === null ? [] : [subagent.childThreadId], diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index 28949b8852a1..4430364ee01f 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -1,12 +1,17 @@ import type { NodeId, + OrchestrationV2ConversationMessage, OrchestrationV2PendingBackgroundTask, + OrchestrationV2ProviderThread, OrchestrationV2ProviderTurn, OrchestrationV2RestartCancelledBackgroundWork, OrchestrationV2Run, OrchestrationV2RunAttempt, + OrchestrationV2Subagent, OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import { subagentHoldsCompletion } from "@t3tools/provider-core/server/subagentProjection"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestRootProviderFailure, runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; type Work = OrchestrationV2RestartCancelledBackgroundWork; @@ -212,14 +217,69 @@ export function cancelledBeforeStart( return run.status === "cancelled" && neverStarted(run); } +/** A follow-up task's runs: those tagged with it, and the untagged wakes for subagents they started. */ +export function delegatedTaskRuns( + projection: { + readonly runs: ReadonlyArray; + readonly messages: ReadonlyArray< + Pick + >; + }, + taskId: NodeId, +): ReadonlyArray { + const owned = new Set( + projection.runs.filter((run) => run.delegatedTaskId === taskId).map((run) => run.id), + ); + const untagged = new Set( + projection.runs.filter((run) => run.delegatedTaskId === undefined).map((run) => run.id), + ); + for (let grew = owned.size > 0; grew;) { + grew = false; + for (const { runId, delegatedCompletion } of projection.messages) { + if ( + runId === null || + !untagged.has(runId) || + owned.has(runId) || + delegatedCompletion === undefined || + !owned.has(delegatedCompletion.parentRunId) + ) + continue; + owned.add(runId); + grew = true; + } + } + return projection.runs.filter((run) => owned.has(run.id)); +} + +/** + * A follow-up task's runs still owe a subagent they started its result, or the child still + * awaits background work, as the original task waits on both. + */ +export function delegatedTaskAwaitsWork( + runs: ReadonlyArray, + child: { + readonly subagents: ReadonlyArray< + Pick + >; + readonly providerThreads: ReadonlyArray< + Pick + >; + }, +): boolean { + const runIds = new Set(runs.map((run) => run.id)); + return ( + child.subagents.some((task) => runIds.has(task.runId) && subagentHoldsCompletion(task)) || + child.providerThreads.some((thread) => + backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []), + ) + ); +} + /** A follow-up task's latest run, passing over queued work it no longer waits on, as the original task does. */ export function delegatedTaskResultRun( runs: ReadonlyArray, - taskId: NodeId, ): OrchestrationV2Run | undefined { - const taskRuns = runs - .filter((run) => run.delegatedTaskId === taskId) - .toSorted((a, b) => b.ordinal - a.ordinal); + const taskRuns = runs.toSorted((a, b) => b.ordinal - a.ordinal); const awaited = taskRuns.filter( (run) => !cancelledBeforeStart(run) && !(run.status === "queued" && run.queueHeld === true), ); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index fc6a65776593..0e8898cf0623 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1247,6 +1247,98 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }), ); +it.effect("prompts the provider only after a forked baseline capture finishes", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:run-execution-forked-baseline"); + const runId = RunId.make("run:run-execution-forked-baseline"); + const attemptId = RunAttemptId.make("attempt:run-execution-forked-baseline"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const checkpointScope = { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-forked-baseline"), + } as OrchestrationV2CheckpointScope; + const run = { id: runId, threadId, ordinal: 1, providerInstanceId } as OrchestrationV2Run; + const order = yield* Ref.make>([]); + const layerTest = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + McpAppModelContext.layerEmpty, + PreviewAgentActivity.layer, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureBaseline: () => + Effect.sleep("1 second").pipe( + Effect.andThen(Ref.update(order, (current) => [...current, "baseline"])), + ), + }), + layerTurnItemPositionsTest, + mockEventSink({}), + IdAllocator.layer, + ThreadCommandExecutor.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + ServerSettings.layerTest(), + ), + ), + ); + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + const baseline = yield* runExecution.forkBaseline({ + appThread: rootAppThread(threadId), + run, + checkpointScope, + }); + const started = yield* runExecution + .startRootRun({ + commandId: CommandId.make("command:run-execution-forked-baseline"), + appThread: rootAppThread(threadId), + providerSessionId: ProviderSessionId.make("session:run-execution-forked-baseline"), + session: { + events: Stream.never, + startTurn: () => Ref.update(order, (current) => [...current, "prompt"]), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + run, + rootNode: { + id: NodeId.make("node:run-execution-forked-baseline"), + } as OrchestrationV2ExecutionNode, + checkpointScope, + providerThread: { + id: ProviderThreadId.make("provider-thread:run-execution-forked-baseline"), + driver, + } as OrchestrationV2ProviderThread, + attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, + attemptId, + baseline, + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make("message:run-execution-forked-baseline"), + text: "Wait for the baseline.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(started); + }).pipe(Effect.provide(layerTest)); + + assert.deepEqual(yield* Ref.get(order), ["baseline", "prompt"]); + }), +); + it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)( "handles %s before the provider turn starts", (scenario) => diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 612a130e019a..10f10a98de0a 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -546,6 +546,8 @@ export class RunExecutionStartError extends Schema.TaggedError()( "RunExecutionIngestError", { @@ -557,6 +559,7 @@ export class RunExecutionIngestError extends Schema.TaggedError; readonly providerTurnOrdinal: number; readonly nativeThreadHasTurns?: boolean; readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect< @@ -593,10 +598,20 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; } +type RunBaselineInput = Pick< + RunExecutionServiceV2StartRootRunInput, + "appThread" | "run" | "checkpointScope" +>; + export interface RunExecutionServiceV2Shape { readonly startRootRun: ( input: RunExecutionServiceV2StartRootRunInput, ) => Effect.Effect; + /** + * Starts the run's baseline capture on a child fiber so it overlaps provider session startup. + * Pass the returned effect to `startRootRun`, which awaits it before prompting the provider. + */ + readonly forkBaseline: (input: RunBaselineInput) => Effect.Effect>; } export class RunExecutionServiceV2 extends Context.Service< @@ -915,7 +930,34 @@ export const layer: Layer.Layer< }, ); + const captureRunBaseline = Effect.fn("RunExecutionService.captureBaseline")(function* ( + input: RunBaselineInput, + ) { + // Side chats share the parent's worktree; a snapshot would capture the parent's edits. + if (input.appThread.lineage.sideChat === true) return; + yield* checkpointService + .captureBaseline({ + scope: input.checkpointScope, + ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + }) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning( + "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", + { runId: input.run.id }, + ), + ), + ); + }); + return RunExecutionServiceV2.of({ + forkBaseline: (input) => + captureRunBaseline(input).pipe( + Effect.forkChild({ startImmediately: true }), + Effect.map(Fiber.join), + ), startRootRun: Effect.fn("RunExecutionService.startRootRun")(function* (input) { const rootTurnOrigin: ProviderUsageAnalytics.ProviderTurnOrigin = { createdBy: input.message.createdBy, @@ -935,6 +977,12 @@ export const layer: Layer.Layer< ), ), ); + const failedProviderTurnId = + input.attempt.providerTurnId ?? + idAllocator.derive.providerTurn({ + driver: input.providerThread.driver, + nativeTurnId: `failed:${input.attempt.id}`, + }); const makeFailedTerminalEvent = ( failure: OrchestrationV2ProviderFailure, failureItemOrdinal: number, @@ -942,12 +990,7 @@ export const layer: Layer.Layer< type: "turn.terminal", driver: input.providerThread.driver, providerThreadId: input.providerThread.id, - providerTurnId: - input.attempt.providerTurnId ?? - idAllocator.derive.providerTurn({ - driver: input.providerThread.driver, - nativeTurnId: `failed:${input.attempt.id}`, - }), + providerTurnId: failedProviderTurnId, runOrdinal: input.run.ordinal, failureItemOrdinal, status: "failed", @@ -962,24 +1005,7 @@ export const layer: Layer.Layer< .responseStreamingMode, ), ); - // Side chats share the parent's worktree; a snapshot would capture the parent's edits. - yield* ( - input.appThread.lineage.sideChat === true - ? Effect.void - : checkpointService.captureBaseline({ - scope: input.checkpointScope, - ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - ).pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (_cause) => - Effect.logWarning( - "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", - { runId: input.run.id }, - ), - ), - ); + yield* input.baseline ?? captureRunBaseline(input); if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) @@ -1112,8 +1138,14 @@ export const layer: Layer.Layer< // Set when ingestion ends or finalization settles the run's open items, which a later // streaming snapshot must not reopen. let idleFlushClosed = false; + const workspaceHandedOff = Effect.suspend(() => + input.shouldFinalizeRun === undefined || input.hasWorkspaceMoved === undefined + ? Effect.succeed(false) + : input.hasWorkspaceMoved(), + ); const finalizeRootRun = Effect.fn("RunExecutionService.finalizeRootRun")(function* ( terminal: ProviderTerminalEvent, + abandonedProviderTurn?: OrchestrationV2ProviderTurn, ) { if (yield* Ref.get(rootRunFinalized)) { return; @@ -1149,6 +1181,7 @@ export const layer: Layer.Layer< : { loadRunInterruptReason: input.loadRunInterruptReason }), openRunOwnedSubagents: openSubagents, openRunOwnedSubagentsTerminal: terminal, + ...(abandonedProviderTurn === undefined ? {} : { abandonedProviderTurn }), terminal: settledTerminal, failureItemPersisted: settledTerminal.status === "failed", refreshAfterTurn, @@ -1161,11 +1194,7 @@ export const layer: Layer.Layer< // for it: the run completes, while its native subagents still settle // as interrupted. The completed write refuses in the same transaction // as a stored Stop request, so a racing Stop stays authoritative. - const handedOff = - terminal.status === "interrupted" && - input.shouldFinalizeRun !== undefined && - input.hasWorkspaceMoved !== undefined && - (yield* input.hasWorkspaceMoved()); + const handedOff = terminal.status === "interrupted" && (yield* workspaceHandedOff); if (!handedOff || !(yield* writeTerminal({ ...terminal, status: "completed" }))) { yield* writeTerminal(terminal); } @@ -1722,14 +1751,51 @@ export const layer: Layer.Layer< (cause) => Effect.gen(function* () { const completedAt = yield* DateTime.now; + const routing = yield* Ref.get(eventRouting); + const rootProviderTurn = yield* Ref.get(latestRootProviderTurn); + const squashed = Cause.squash(cause); + // An exclusive session closes when its thread detaches, and no terminal + // follows. The process is gone, so a handoff settles here with no interrupt. + if ( + isRunExecutionIngestError(squashed) && + isProviderEventStreamError(squashed.cause) && + squashed.cause.detachedThreadId === input.run.threadId && + (yield* workspaceHandedOff) + ) { + const handedOff = yield* finalizeRootRun( + { + type: "turn.terminal", + driver: input.providerThread.driver, + providerThreadId: input.providerThread.id, + providerTurnId: routing.rootProviderTurnId ?? failedProviderTurnId, + runOrdinal: input.run.ordinal, + status: "interrupted", + failure: null, + threadDisposition: "reusable", + }, + rootProviderTurn === null || + isTerminalProviderTurnStatus(rootProviderTurn.status) + ? undefined + : { ...rootProviderTurn, status: "interrupted", completedAt }, + ).pipe( + Effect.as(true), + Effect.catchCauseIf( + (settleCause) => !Cause.hasInterruptsOnly(settleCause), + (settleCause) => + Effect.logWarning( + "orchestration V2 failed to settle a run after its workspace handoff", + { runId: input.run.id, cause: settleCause }, + ).pipe(Effect.as(false)), + ), + ); + if (handedOff) return; + } yield* Effect.logWarning("orchestration V2 provider event ingestion failed", { runId: input.run.id, cause, }); if (yield* Ref.get(rootRunFinalized)) return; const providerThread = yield* Ref.get(latestProviderThread); - const routing = yield* Ref.get(eventRouting); - const rootProviderTurn = yield* Ref.get(latestRootProviderTurn); // Stop background work too; the failed consumer cannot persist terminal events. const abandonedProviderTurn = routing.rootProviderTurnId !== null && !routing.rootTurnEnded @@ -1767,7 +1833,6 @@ export const layer: Layer.Layer< : null; const latestItemOrdinal = yield* Ref.get(latestTurnItemOrdinal); const openSubagents = yield* Ref.get(openRunOwnedSubagents); - const squashed = Cause.squash(cause); let nextRetryWarningAt = completedAt; yield* writeFinalRunEvents({ run: input.run, diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 2dcbbd2ba2e1..7b12f72f4cea 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -443,114 +443,153 @@ it.live("retries a failed selection restart session open before completing", () }), ); -it.live("a workspace handoff during a run does not leave the run interrupted", () => - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("workspace-handoff-lifecycle"); - const handoffCwd = yield* checkpointWorkspace("workspace-handoff-lifecycle-target"); - const threadId = ThreadId.make("thread:workspace-handoff-lifecycle"); - const state = yield* Ref.make({ - activeTurn: null, - opened: [], - started: [], - closedSessionCount: 0, - failedReplacementOpen: false, - }); - const layerRegistry = ProviderAdapterRegistry.layerSingle(makeRestartAdapter(state)); +// regression: #22971 an exclusive session closes on detach; that failed the run and held the queue +it.live.each(["pooled", "exclusive"] as const)( + "a workspace handoff on a %s session completes its run and starts the queued continuation", + (mode) => + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`workspace-handoff-${mode}`); + const handoffCwd = yield* checkpointWorkspace(`workspace-handoff-${mode}-target`); + const threadId = ThreadId.make(`thread:workspace-handoff:${mode}`); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + failedReplacementOpen: false, + }); + const base = makeRestartAdapter( + state, + mode === "pooled" ? pooledCapabilities : exclusiveCapabilities, + ); + // A session that closes on detach is never asked to interrupt: Claude's cannot answer. + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = + mode === "pooled" + ? base + : { + ...base, + openSession: (input) => + base.openSession(input).pipe( + Effect.map((session) => ({ + ...session, + interruptTurn: () => Effect.die("an exclusive handoff must not interrupt"), + })), + ), + }; + const layerRegistry = ProviderAdapterRegistry.layerSingle(adapter); - const projection = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const eventSink = yield* EventSink.EventSinkV2; + const projection = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:workspace-handoff:create"), - threadId, - projectId: ProjectId.make("project:workspace-handoff"), - title: "Workspace handoff", - modelSelection: initialSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - const running = yield* eventSink - .stream({ + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:workspace-handoff:create"), threadId, - afterSequence: yield* eventSink.latestSequence({ threadId }), - }) - .pipe( - Stream.map((stored) => stored.event), - Stream.filter( - (event) => event.type === "provider-turn.updated" && event.payload.status === "running", - ), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:workspace-handoff:first"), - threadId, - messageId: MessageId.make("message:workspace-handoff:first"), - text: "move to a worktree", - attachments: [], - modelSelection: initialSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* Fiber.join(running); - yield* worker.drain(); + projectId: ProjectId.make("project:workspace-handoff"), + title: "Workspace handoff", + modelSelection: initialSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const running = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:workspace-handoff:first"), + threadId, + messageId: MessageId.make("message:workspace-handoff:first"), + text: "move to a worktree", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* Fiber.join(running); + yield* worker.drain(); - const settled = yield* eventSink - .stream({ + const settled = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.status !== "running" && + event.payload.status !== "queued" && + event.payload.status !== "starting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + // What t3_worktree_handoff dispatches while the calling turn is still running. + const bound = yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("command:workspace-handoff:rebind"), threadId, - afterSequence: yield* eventSink.latestSequence({ threadId }), - }) - .pipe( - Stream.map((stored) => stored.event), - Stream.filter( - (event) => - event.type === "run.updated" && - event.payload.status !== "running" && - event.payload.status !== "queued" && - event.payload.status !== "starting", + branch: "repro-handoff", + worktreePath: handoffCwd, + continuation: { + messageId: MessageId.make("message:workspace-handoff:continuation"), + text: "continue in the worktree", + }, + }); + // The detach can settle the run at once, so its follow-up must exist in the same commit. + assert.deepEqual( + bound.storedEvents.flatMap(({ event }) => + event.type === "run.created" ? [event.payload.status] : [], ), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, + ["queued"], ); - // What t3_worktree_handoff dispatches while the calling turn is still running. - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("command:workspace-handoff:rebind"), - threadId, - branch: "repro-handoff", - worktreePath: handoffCwd, - }); - yield* Fiber.join(settled); - yield* worker.drain(); - return yield* orchestrator.getThreadProjection(threadId); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "workspace-handoff-lifecycle" }, - layerRegistry, + yield* Fiber.join(settled); + yield* worker.drain(); + return yield* orchestrator.getThreadProjection(threadId); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: `workspace-handoff-${mode}` }, + layerRegistry, + ), ), - ), - ); + ); - assert.equal(projection.thread.worktreePath, handoffCwd); - assert.equal(projection.runs[0]?.status, "completed"); - assert.isNotNull(projection.runs[0]?.checkpointId ?? null); - assert.isFalse( - projection.turnItems.some((item) => item.type === "run_interrupt_result"), - "an intentional workspace handoff must not project an interrupt result", - ); - }), + assert.equal(projection.thread.worktreePath, handoffCwd); + assert.equal(projection.runs[0]?.status, "completed"); + assert.isNotNull(projection.runs[0]?.checkpointId ?? null); + assert.isFalse( + projection.turnItems.some( + (item) => item.type === "run_interrupt_result" || item.type === "error", + ), + "an intentional workspace handoff must not project an interrupt result or a failure", + ); + assert.deepEqual( + (yield* Ref.get(state)).started.map((turn) => turn.cwd), + [cwd, handoffCwd], + ); + }), ); it.live("Stop during a restart interrupts the turn the restart could not stop", () => diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index c0c8cfae5a80..acfd20af7874 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -11,6 +11,7 @@ import { ProviderInstanceId, ProviderThreadId, ProviderTurnId, + ScheduledTaskId, ThreadId, TurnItemId, type ModelSelection, @@ -511,6 +512,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* onSteer: ( turn: ProviderAdapter.ProviderAdapterV2SteerInput, ) => Effect.Effect = () => Effect.void, + extraAdapters: ReadonlyArray = [], ) { const driver = ProviderDriverKind.make(nativeGoals === undefined ? "codex" : "opencode"); const cwd = yield* checkpointWorkspace(name); @@ -647,7 +649,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* }; const layer = ProviderReplayHarness.layerWithRegistry( { name }, - ProviderAdapterRegistry.layerSingle(adapter), + ProviderAdapterRegistry.layerFromAdapters([adapter, ...extraAdapters]), { runEffectWorker: false }, ); const startFirstTurn = Effect.gen(function* () { @@ -2158,6 +2160,193 @@ it.effect("steers a promoted queued message with the selection it was queued wit }), ); +it.effect("steers a promoted scheduled delivery with the thread's current selection", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-scheduled-selection", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + // Bound scheduled tasks send like ScheduledTaskService: no model of their own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("scheduled"), + threadId, + messageId: MessageId.make("message:scheduled"), + scheduledTaskId: ScheduledTaskId.make("task:scheduled"), + text: "scheduled", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.deepEqual(queuedRun.modelSelection, runSelection); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("select-composer"), + threadId, + modelSelection: composerSelection, + }); + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["scheduled"]); + assert.deepEqual(projection.thread.modelSelection, composerSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + +const claudeSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-test", +}; +const claudeAdapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + instanceId: claudeSelection.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: () => Effect.die("the Claude instance never starts a turn here"), +}; + +// Queues a scheduled delivery behind the Codex turn, stops that turn (Stop holds the queue), then +// switches the thread to Claude: a thread changes instance only while no run is active. +const scheduledDeliveryAfterClaudeSwitch = (name: string, text: string) => + Effect.gen(function* () { + const harness = yield* nextTurnSelectionHarness( + name, + false, + true, + Effect.void, + true, + undefined, + undefined, + [claudeAdapter], + ); + const queueAndSwitch = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = yield* harness.startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("scheduled"), + threadId, + messageId: MessageId.make("message:scheduled"), + scheduledTaskId: ScheduledTaskId.make("task:scheduled"), + text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.equal(queuedRun.providerInstanceId, instanceId); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const first = harness.started[0]!; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop"), + threadId, + runId: first.runId, + holdQueue: true, + }); + yield* worker.drain(); + const stopped = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.equal(stopped.find((run) => run.id === first.runId)?.status, "interrupted"); + const held = stopped.find((run) => run.id === queuedRun.id); + assert.equal(held?.status, "queued"); + assert.isTrue(held?.queueHeld); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("select-claude"), + threadId, + modelSelection: claudeSelection, + }); + return { threadId, queuedRun }; + }); + return { ...harness, queueAndSwitch }; + }); + +it.effect("checks an edited scheduled /goal against the thread's current provider", () => + Effect.scoped( + Effect.gen(function* () { + const { layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( + "edit-scheduled-goal", + "scheduled", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { threadId, queuedRun } = yield* queueAndSwitch; + const editError = yield* orchestrator + .dispatch({ + type: "queued-run.edit", + commandId: CommandId.make("edit"), + threadId, + runId: queuedRun.id, + text: "/goal pause", + }) + .pipe(Effect.flip); + + assert.match(String(editError.cause), /Claude goals cannot be paused or resumed/); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).messages.find( + (message) => message.id === queuedRun.userMessageId, + )?.text, + "scheduled", + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("checks a queued scheduled /goal against the thread's current provider at start", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( + "start-scheduled-goal", + "/goal pause", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const { threadId, queuedRun } = yield* queueAndSwitch; + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume"), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const failure = projection.turnItems.find( + (item) => item.type === "error" && item.runId === queuedRun.id, + ); + assert.equal(started.length, 1); + assert.equal(projection.runs.find((run) => run.id === queuedRun.id)?.status, "failed"); + assert.equal( + failure?.type === "error" ? failure.failure.code : null, + "queued_start_failed", + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("marks the model switch on a promoted message that restarts the turn", () => Effect.gen(function* () { const { started, layer, startFirstTurn } = yield* nextTurnSelectionHarness( diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index c44c3a70f19b..33d5fb657fa7 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -2781,6 +2781,12 @@ it.effect.each([ startFromOrigin: true, origin: false, }, + { + name: "was deleted on its remote", + baseRef: "origin/development", + startFromOrigin: true, + origin: true, + }, { name: "is a previous checkout there never was", baseRef: "-", @@ -2791,6 +2797,7 @@ it.effect.each([ Effect.gen(function* () { const harness = makeHarness({ hasCommit: ({ refName }) => Effect.succeed(refName === "HEAD"), + fetchWorktreeBaseCommit: () => Effect.succeed(null), git: { resolveWorktreeBaseRemote: () => Effect.succeed(testCase.origin ? { remoteName: "origin", branchName: "main" } : null), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index b354ba6737e3..67f3b5402de7 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -346,42 +346,63 @@ const make = Effect.gen(function* () { "ThreadLaunchService.checkWorktreeBase", )(function* (input) { const strategy = input.workspaceStrategy; - // launch() turns a Scratch worktree request into a root launch. - if (strategy.type !== "worktree" || (yield* managedFolders.isScratchProject(input.projectId))) - return; - if (input.commandId !== undefined) { - const receipt = yield* receipts - .getByCommandId(input.commandId) - .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isSome(receipt) && receipt.value.status === "accepted") return; - } - const project = Option.getOrUndefined( - yield* projects.getById(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())), + if (strategy.type !== "worktree") return; + const commandId = input.commandId; + const [isScratch, receipt, projectOption] = yield* Effect.all( + [ + managedFolders.isScratchProject(input.projectId), + commandId === undefined + ? Effect.succeedNone + : receipts.getByCommandId(commandId).pipe(Effect.orElseSucceed(() => Option.none())), + projects.getById(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())), + ], + { concurrency: "unbounded" }, ); + // launch() turns a Scratch worktree request into a root launch. + if (isScratch) return; + if (Option.isSome(receipt) && receipt.value.status === "accepted") return; + const project = Option.getOrUndefined(projectOption); if (project === undefined) return; const cwd = project.workspaceRoot; - const repositoryRoot = yield* git - .repositoryRoot(cwd) - .pipe(Effect.orElseSucceed(() => undefined)); + const { baseRef, branch } = strategy; + const checksBranch = branch !== undefined && !isTemporaryWorktreeBranch(branch); + // Errors keep their order below: missing repository, then base ref, then branch conflict. + const [repositoryRoot, hasBaseCommit, localBranches] = yield* Effect.all( + [ + git.repositoryRoot(cwd).pipe(Effect.orElseSucceed(() => undefined)), + // `:/text` is a commit search, which cannot be asked for a commit type. + baseRef.startsWith(":") + ? Effect.succeed(true) + : // `git worktree add` reads `-` as the previous checkout. + git + .hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef }) + .pipe(Effect.orElseSucceed(() => true)), + checksBranch + ? git + .listLocalBranchNames(cwd) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])) + : Effect.succeed>([]), + ], + { concurrency: "unbounded" }, + ); if (repositoryRoot === null) return yield* new ThreadLaunchNotRepositoryError({ projectId: project.id, projectTitle: project.title, workspaceRoot: cwd, }); - const { baseRef, branch } = strategy; const baseResolves = - // `:/text` is a commit search, which cannot be asked for a commit type. - baseRef.startsWith(":") || + hasBaseCommit || (yield* Effect.gen(function* () { - // `git worktree add` reads `-` as the previous checkout. - if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef })) - return true; - if ( - strategy.startFromOrigin === true && - (yield* git.resolveWorktreeBaseRemote({ cwd, refName: baseRef })) !== null - ) - return true; + if (strategy.startFromOrigin === true) { + const remote = yield* git.resolveWorktreeBaseRemote({ cwd, refName: baseRef }); + // The remote may have deleted the branch; ask it rather than trusting the name. + if ( + remote !== null && + (yield* git.fetchWorktreeBaseCommit({ cwd, refName: baseRef, remote })) !== null + ) + return true; + } // git worktree add accepts remote-tracking branches; let Git decide when any matching ref exists. if (yield* git.hasRefNamed({ cwd, refName: baseRef })) return true; return !(yield* git.hasCommit({ cwd, refName: "HEAD" })); @@ -394,10 +415,8 @@ const make = Effect.gen(function* () { baseRef, }); // Provisioning flattens temporary names that collide, so only explicit names are refused. - if (branch === undefined || isTemporaryWorktreeBranch(branch)) return; - const conflictingBranch = (yield* git - .listLocalBranchNames(cwd) - .pipe(Effect.orElseSucceed((): ReadonlyArray => []))).find( + if (!checksBranch) return; + const conflictingBranch = localBranches.find( (existing) => existing.startsWith(`${branch}/`) || branch.startsWith(`${existing}/`), ); if (conflictingBranch !== undefined) @@ -992,7 +1011,7 @@ const make = Effect.gen(function* () { return yield* prepare; } const afterSequence = yield* threads.getThreadEventSequence(threadId); - const projection = yield* threads.getThreadRecords(threadId, ["runs"]); + const projection = yield* threads.getThreadRecords(threadId, ["runs"], { runIds: [runId] }); if (projection.runs.find((run) => run.id === runId)?.status !== "preparing") return; const interruption = threads .streamStoredEventsFrom({ threadId, afterSequence, eventType: "run.updated" }) @@ -1220,49 +1239,43 @@ const make = Effect.gen(function* () { } } + // Fork preparation before the full projection read; it is only part of the reply. + const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : true; + if (shouldSchedule && (yield* reservePreparation(input.commandId))) { + yield* Effect.gen(function* () { + const current = + runId === null + ? null + : yield* threads + .getThreadRecords(threadId, ["runs"], { runIds: [runId] }) + .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); + if ( + current !== null && + !current.runs.some((run) => run.id === runId && run.status === "preparing") + ) { + return yield* releasePreparation(input.commandId); + } + const boundWorktreePath = current?.thread.worktreePath ?? null; + const preparationStrategy: ThreadLaunchWorkspaceStrategy = + Option.isSome(launchReceipt) && + workspaceStrategy.type === "root" && + boundWorktreePath !== null + ? { + type: "existing_worktree", + worktreePath: boundWorktreePath, + branch: workspaceStrategy.branch, + } + : workspaceStrategy; + yield* schedulePreparation( + { ...input, workspaceStrategy: preparationStrategy }, + threadId, + runId, + ); + }).pipe(Effect.onError(() => releasePreparation(input.commandId))); + } const projection = yield* threads .getThreadProjection(threadId) .pipe(Effect.mapError(mapError(input, "create-thread", threadId))); - const runIsPreparing = - runId !== null && - projection.runs.some((run) => run.id === runId && run.status === "preparing"); - const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing; - const boundWorktreePath = projection.thread.worktreePath; - const preparationStrategy: ThreadLaunchWorkspaceStrategy = - Option.isSome(launchReceipt) && - workspaceStrategy.type === "root" && - boundWorktreePath !== null - ? { - type: "existing_worktree", - worktreePath: boundWorktreePath, - branch: workspaceStrategy.branch, - } - : workspaceStrategy; - if (shouldSchedule) { - const ownsPreparation = yield* reservePreparation(input.commandId); - if (ownsPreparation) { - yield* Effect.gen(function* () { - const preparationStillRequired = - runId === null - ? true - : yield* threads.getThreadRecords(threadId, ["runs"], { runIds: [runId] }).pipe( - Effect.map((current) => - current.runs.some((run) => run.id === runId && run.status === "preparing"), - ), - Effect.mapError(mapError(input, "update-thread", threadId)), - ); - if (preparationStillRequired) { - yield* schedulePreparation( - { ...input, workspaceStrategy: preparationStrategy }, - threadId, - runId, - ); - } else { - yield* releasePreparation(input.commandId); - } - }).pipe(Effect.onError(() => releasePreparation(input.commandId))); - } - } return { threadId, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index ac26386f34d8..08291f557124 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -38,6 +38,11 @@ import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; +import { + ThreadWorktreeRestore, + WorktreeRecreateError, +} from "../workspace/restoreMissingWorktree.ts"; it("stamps authoritative provenance on commands that create threads or messages", () => { const command: OrchestrationV2Command = { @@ -199,6 +204,101 @@ it.effect( }, ); +it.effect.each([ + { label: "fails a send that starts a turn", running: false }, + { label: "still steers a running turn", running: true }, +])("sendToThread with a missing worktree that cannot be recreated $label", ({ running }) => { + const projectId = ProjectId.make("project:thread-management:worktree-gone"); + const threadId = ThreadId.make("thread:thread-management:worktree-gone"); + const dispatched: Array = []; + const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe( + Layer.provide( + Layer.succeed(ThreadWorktreeRestore, { + restore: () => + Effect.fail( + new WorktreeRecreateError({ + worktreePath: "/worktrees/gone", + branch: "t3/gone", + cause: new Error("branch_checked_out_in_worktree"), + }), + ), + }), + ), + Layer.provide( + Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({ + ensureTranscript: () => Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), + }), + ), + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { + id: threadId, + projectId, + deletedAt: null, + archivedAt: null, + worktreePath: "/worktrees/gone", + branch: "t3/gone", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test" }, + }, + runs: running + ? [ + { + id: RunId.make("run:thread-management:worktree-gone"), + status: "running", + activeAttemptId: "attempt:thread-management:worktree-gone", + }, + ] + : [], + providerTurns: running + ? [ + { + runAttemptId: "attempt:thread-management:worktree-gone", + status: "running", + }, + ] + : [], + messages: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection), + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command.type); + return { sequence: 1, storedEvents: [] } as never; + }), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provideMerge(layerReceiptsTest), + ); + + return Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + const error = yield* Effect.flip( + threads.sendToThread({ + projectId, + threadId, + commandId: CommandId.make("command:thread-management:worktree-gone"), + messageId: MessageId.make("message:thread-management:worktree-gone"), + text: "Reply with ok.", + attachments: [], + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }), + ); + // The mock records no message, so a dispatched send ends in the projection read. + expect(error._tag).toBe( + running + ? "ThreadManagementDurableRunProjectionError" + : "ThreadManagementWorktreeRestoreError", + ); + expect(dispatched).toEqual(running ? ["message.dispatch"] : []); + }).pipe(Effect.provide(layerTest)); +}); + it.effect("waitForThread follows a restart continuation instead of the cut run", () => Effect.gen(function* () { const projectId = ProjectId.make("project:thread-management:wait-restart"); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 73bd1c67df28..2c52238b8453 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -3,6 +3,7 @@ import type { OrchestrationV2SearchThreadResult, } from "@t3tools/contracts"; import type { + ActiveThreadListEntry, ProjectionRecordField, ProjectionRecordFilter, ProjectionRecords, @@ -30,7 +31,6 @@ import { type OrchestrationV2ThreadShellSnapshot, type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadTranscript, - type OrchestrationV2ThreadShell, type OrchestrationV2TurnItem, ProjectId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, @@ -70,6 +70,7 @@ import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimi import { projectTurnItemForDetail } from "./WireProjection.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { ThreadWorktreeRestore } from "../workspace/restoreMissingWorktree.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -317,6 +318,19 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro } } +export class ThreadManagementWorktreeRestoreError extends Schema.TaggedError()( + "ThreadManagementWorktreeRestoreError", + { + threadId: ThreadId, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const detail = this.cause instanceof Error ? ` ${this.cause.message}` : ""; + return `Couldn't recreate the missing workspace of thread ${this.threadId}, so the message was not sent.${detail}`; + } +} + export class ThreadManagementProviderInstanceDisabledError extends Schema.TaggedError()( "ThreadManagementProviderInstanceDisabledError", { @@ -361,6 +375,7 @@ export const ThreadManagementError = Schema.Union([ ThreadManagementProjectThreadsListError, ThreadManagementDurableRunProjectionError, ThreadManagementProviderInstanceDisabledError, + ThreadManagementWorktreeRestoreError, ]); export type ThreadManagementError = typeof ThreadManagementError.Type; @@ -385,6 +400,7 @@ export interface ThreadManagementServiceShape { readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"]; readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; + readonly getMessagesById: Orchestrator.OrchestratorV2["Service"]["getMessagesById"]; readonly getTurnItem: (input: { readonly threadId: ThreadId; readonly itemId: TurnItemId; @@ -436,7 +452,7 @@ export interface ThreadManagementServiceShape { readonly listProjectThreads: (input: { readonly projectId?: ProjectId | undefined; readonly includeSubagents: boolean; - }) => Effect.Effect, ThreadManagementError>; + }) => Effect.Effect, ThreadManagementError>; readonly sendToThread: ( input: ThreadManagementSendInput, ) => Effect.Effect; @@ -592,6 +608,7 @@ const make = Effect.gen(function* () { const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const serverSettings = yield* ServerSettings.ServerSettingsService; const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + const worktreeRestore = yield* ThreadWorktreeRestore; const assertProviderInstanceEnabled: ThreadManagementServiceShape["assertProviderInstanceEnabled"] = (input) => @@ -733,6 +750,18 @@ const make = Effect.gen(function* () { const assertDispatchTargetEnabled = (command: OrchestrationV2ServerCommand) => { switch (command.type) { + case "thread.metadata.update": + return command.continuation === undefined + ? Effect.void + : orchestrator.getThreadRecords(command.threadId, []).pipe( + Effect.flatMap((projection) => + assertProviderInstanceEnabled({ + projectId: projection.thread.projectId, + commandId: command.commandId, + instanceId: projection.thread.modelSelection.instanceId, + }), + ), + ); case "message.dispatch": return orchestrator.getThreadRecords(command.threadId, []).pipe( Effect.flatMap((projection) => @@ -807,33 +836,28 @@ const make = Effect.gen(function* () { ); const listProjectThreads: ThreadManagementServiceShape["listProjectThreads"] = (input) => - orchestrator - .getShellSnapshot({ - location: "active", - ...(input.projectId === undefined ? {} : { projectId: input.projectId }), - }) - .pipe( - Effect.mapError( - (cause) => - new ThreadManagementProjectThreadsListError({ - projectId: input.projectId, - cause, - }), - ), - Effect.map((snapshot) => - snapshot.threads - .filter((thread) => - input.includeSubagents - ? thread.lineage.sideChat !== true - : isListedThreadLineage(thread.lineage), - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || - right.id.localeCompare(left.id), - ), - ), - ); + orchestrator.listActiveThreads(input.projectId).pipe( + Effect.mapError( + (cause) => + new ThreadManagementProjectThreadsListError({ + projectId: input.projectId, + cause, + }), + ), + Effect.map((threads) => + threads + .filter((thread) => + input.includeSubagents + ? thread.lineage.sideChat !== true + : isListedThreadLineage(thread.lineage), + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || + right.id.localeCompare(left.id), + ), + ), + ); const sendToThread: ThreadManagementServiceShape["sendToThread"] = Effect.fn( "ThreadManagementService.sendToThread", @@ -873,6 +897,26 @@ const make = Effect.gen(function* () { }; } + // Turn start would fail on its own after the send reported success; a replay keeps its answer. + // A live steer starts no turn, so it needs no workspace. + if (dispatchMode.type !== "steer_active") { + yield* worktreeRestore + .restore(target.thread) + .pipe( + Effect.catch((cause) => + hasReceipt(input.commandId).pipe( + Effect.flatMap((replay) => + replay + ? Effect.void + : Effect.fail( + new ThreadManagementWorktreeRestoreError({ threadId: input.threadId, cause }), + ), + ), + ), + ), + ); + } + const dispatched = yield* dispatch({ type: "message.dispatch", commandId: input.commandId, @@ -1667,6 +1711,13 @@ const make = Effect.gen(function* () { Effect.andThen(orchestrator.getMessageCount(threadId)), ); }), + getMessagesById: Effect.fn("ThreadManagementService.getMessagesById")( + function (threadId, messageIds) { + return ensureProjectionTranscript(threadId).pipe( + Effect.andThen(orchestrator.getMessagesById(threadId, messageIds)), + ); + }, + ), getTurnItem: Effect.fn("ThreadManagementService.getTurnItem")(function (input) { return ensureProjectionTranscript(input.threadId).pipe( Effect.andThen(orchestrator.getTurnItem(input)), @@ -1726,6 +1777,12 @@ const make = Effect.gen(function* () { }); }); +const layerThreadWorktreeRestoreNoop = Layer.succeed(ThreadWorktreeRestore, { + restore: Effect.fn("ThreadWorktreeRestore.restore")(function () { + return Effect.void; + }), +}); + const layerLegacyV1ThreadImporterNoop = Layer.succeed( LegacyV1ThreadImporter.LegacyV1ThreadImporter, LegacyV1ThreadImporter.LegacyV1ThreadImporter.of({ @@ -1747,6 +1804,7 @@ export const layer: Layer.Layer< | CommandReceiptStore.CommandReceiptStoreV2 > = Layer.effect(ThreadManagementService, make).pipe( Layer.provide(layerLegacyV1ThreadImporterNoop), + Layer.provide(layerThreadWorktreeRestoreNoop), Layer.provide(ThreadCommandExecutor.layer), ); @@ -1754,6 +1812,7 @@ export const layerWithLegacyImporter: Layer.Layer< ThreadManagementService, never, | LegacyV1ThreadImporter.LegacyV1ThreadImporter + | ThreadWorktreeRestore | Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 | ServerSettings.ServerSettingsService diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index a48cc3ca7cd9..c6a6cdbf7ac1 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -27,6 +27,7 @@ function dispatchWasNotAccepted( case "OrchestratorProviderSelectionBusyError": case "OrchestratorRuntimeModeAboveCeilingError": case "ThreadManagementProviderInstanceDisabledError": + case "ThreadManagementWorktreeRestoreError": return true; default: return false; diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index bcbfe89aa515..bd7154dae96a 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -607,9 +607,16 @@ describe("ThreadPullRequestServiceV2 reads", () => { yield* service.start(); yield* Deferred.succeed(activation, undefined); - // Startup backfill sweep, then one periodic sweep. + // Startup backfill sweep, then periodic sweeps: a branch without a pull request + // is reread after half an hour, not on every sweep. yield* Queue.take(sweeps); yield* service.drain; + for (let minute = 1; minute <= 29; minute += 1) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(sweeps); + yield* service.drain; + } + expect(lookups.map((lookup) => lookup.branch)).toEqual([parent.branch]); yield* TestClock.adjust("1 minute"); yield* Queue.take(sweeps); yield* service.drain; diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index a749621ceb4c..559ea7677aac 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -30,6 +30,7 @@ import { makeLinkedThreadsChangeDetector } from "../pullRequest/linkedThreads.ts import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; +import { makeNoPullRequestRechecks } from "./noPullRequestRechecks.ts"; import * as Orchestrator from "./Orchestrator.ts"; class ThreadPullRequestServiceV2 extends Context.Service< @@ -123,6 +124,7 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const pendingBackfill = new Map(); + const noPullRequestRechecks = makeNoPullRequestRechecks(); const finishBackfill = (threads: ReadonlyArray>) => { for (const thread of threads) pendingBackfill.delete(thread.id); @@ -156,6 +158,7 @@ export const make = Effect.gen(function* () { ) : threadSnapshot.threads; const projects = new Map(projectShells.map((project) => [project.id, project])); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); if (request.backfill) { for (const thread of snapshotThreads) { if ( @@ -185,13 +188,34 @@ export const make = Effect.gen(function* () { JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]), ); + // A thread's own events and a backfill read at once; only scheduled sweeps wait. + const scheduled = new Map( + request.threadId !== null + ? [] + : [...groups].filter( + ([, group]) => + group[0]!.branch !== null && + group.every( + (thread) => thread.branchPullRequest == null && !pendingBackfill.has(thread.id), + ), + ), + ); + const scheduledLookups = noPullRequestRechecks.select( + nowMs, + Array.from(scheduled, ([key, group]) => ({ + key, + activityAtMs: Math.max(...group.map((thread) => DateTime.toEpochMillis(thread.updatedAt))), + })), + ); + yield* Effect.forEach( - groups.values(), - (group) => + groups, + ([key, group]) => Effect.gen(function* () { const first = group[0]!; const project = projects.get(first.projectId); if (project === undefined) return finishBackfill(group); + if (scheduled.has(key) && !scheduledLookups.has(key)) return; const { project: resolvedProject, repository } = yield* resolveProjectForPullRequestDiscovery(project, repositoryIdentities, { refresh: request.refresh, @@ -214,6 +238,7 @@ export const make = Effect.gen(function* () { { cwd, branch: first.branch }, { refresh: request.refresh, confirmTerminal: false }, ); + if (first.branch !== null) noPullRequestRechecks.record(key, nowMs, detected !== null); if (detected !== null && !pullRequestMatchesProject(detected, resolvedProject)) { return finishBackfill(group); } @@ -375,6 +400,7 @@ export const make = Effect.gen(function* () { // Batch concurrent branch lookups into one GitHub GraphQL document. { concurrency: 32, discard: true }, ); + if (request.threadId === null) noPullRequestRechecks.retain(groups); }); // At most one queued request per thread, so a turn end's events share one lookup. diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 80ee7973cfe7..4b677c4f14f3 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -1788,6 +1788,93 @@ describe("ThreadSettlementServiceV2 worker", () => { }), ); + it.effect("rereads a branch without a pull request less often the longer its thread idles", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const recent = makeThread("recent-branch", { + branch: "recent-feature", + latestUserMessageAt: DateTime.makeUnsafe(Date.parse(NOW) - 30 * 60 * 1_000), + }); + const old = makeThread("old-branch", { + branch: "old-feature", + latestUserMessageAt: DateTime.makeUnsafe(Date.parse(NOW) - 2 * DAY_MS), + }); + const fixture = yield* makeHarness({ snapshot: makeSnapshot([recent, old]) }); + const branches = Ref.get(fixture.branchCalls).pipe( + Effect.map((calls) => calls.map((call) => call.branch).sort()), + ); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + for (let minute = 1; minute <= 29; minute += 1) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + } + assert.deepStrictEqual(yield* branches, ["old-feature", "recent-feature"]); + + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* branches, [ + "old-feature", + "recent-feature", + "recent-feature", + ]); + + // Work ending in the thread reads its branch at once. + yield* fixture.publishEvent({ + type: "run.background-work-cancelled", + id: EventId.make("event:old-branch:run-ended"), + threadId: old.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: { runId: RunId.make("run:old-branch"), restartCancelledBackgroundWork: [] }, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* branches, [ + "old-feature", + "old-feature", + "recent-feature", + "recent-feature", + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + + it.effect("a sweep looks up a few branches without a pull request, not every one", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const threads = Array.from({ length: 10 }, (_, index) => + makeThread(`idle-${index}`, { + branch: `idle-${index}`, + latestUserMessageAt: DateTime.makeUnsafe(Date.parse(NOW) - (index + 10) * 60 * 1_000), + }), + ); + const fixture = yield* makeHarness({ snapshot: makeSnapshot(threads) }); + const branches = Ref.get(fixture.branchCalls).pipe( + Effect.map((calls) => calls.map((call) => call.branch).sort()), + ); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + // The most recently active first, then the rest in turn, each once. + assert.deepStrictEqual(yield* branches, ["idle-0", "idle-1", "idle-2", "idle-3"]); + for (let minute = 1; minute <= 5; minute += 1) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + } + assert.deepStrictEqual( + yield* branches, + threads.map((thread) => thread.branch), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + it.effect("a merged branch PR that cannot settle its thread rereads no git state", () => Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index cfcf1b1e057c..f493951983ec 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -35,6 +35,7 @@ import * as TerminalManager from "../terminal/Manager.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ProjectStore from "./ProjectStore.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; +import { makeNoPullRequestRechecks } from "./noPullRequestRechecks.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -60,6 +61,17 @@ function latestMillis(values: ReadonlyArray): number | null { return latest; } +function threadActivityAtMs(thread: ProjectionStore.ProjectionSettlementCandidate): number { + return ( + latestMillis([ + toMillis(thread.latestUserMessageAt), + toMillis(thread.latestRunRequestedAt), + toMillis(thread.latestRunStartedAt), + toMillis(thread.latestRunCompletedAt), + ]) ?? DateTime.toEpochMillis(thread.createdAt) + ); +} + function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: { @@ -228,13 +240,7 @@ export function resolveAutoSettlementAt(input: { }; } if (!isAutoSettlementCandidate(thread, input.nowMs)) return null; - const activityAtMs = - latestMillis([ - toMillis(thread.latestUserMessageAt), - toMillis(thread.latestRunRequestedAt), - toMillis(thread.latestRunStartedAt), - toMillis(thread.latestRunCompletedAt), - ]) ?? DateTime.toEpochMillis(thread.createdAt); + const activityAtMs = threadActivityAtMs(thread); if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) { return DateTime.makeUnsafe(activityAtMs); } @@ -318,6 +324,7 @@ export const make = Effect.gen(function* () { const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; // Repeated thread.settled retains settledAt; run the action only once per settlement. const settleActionRunAt = new Map(); + const noPullRequestRechecks = makeNoPullRequestRechecks(); const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, @@ -545,17 +552,34 @@ export const make = Effect.gen(function* () { return yield* Effect.die(new Error("thread project not found")); } const refresh = group.filter((member) => shellClosedThreads.delete(member.id)).length > 0; + const key = lookupKey(thread); + if (scheduledLookups !== null && !refresh && !scheduledLookups.has(key)) return undefined; // Remembered git state is enough to rule a thread out; only an answer // that would settle it is confirmed against fresh git state. const remembered = yield* git.branchPullRequest( { cwd, branch: thread.branch }, { refresh, confirmTerminal: false }, ); + noPullRequestRechecks.record(key, nowMs, remembered !== null); if (refresh || remembered === null || remembered.state === "open") return remembered; if (!(yield* wouldSettle(group, remembered))) return undefined; return yield* git.branchPullRequest({ cwd, branch: thread.branch }, { freshGitState: true }); }); + // A merge, a shell close and a thread's own events read at once; only scheduled sweeps wait. + const scheduledLookups = + mergedPullRequest !== null || threadId !== undefined + ? null + : noPullRequestRechecks.select( + nowMs, + [...groups] + .filter(([key]) => key.startsWith('["branch",')) + .map(([key, group]) => ({ + key, + activityAtMs: Math.max(...group.map(threadActivityAtMs)), + })), + ); + yield* Effect.forEach( groups.values(), (group) => @@ -577,6 +601,7 @@ export const make = Effect.gen(function* () { ), { concurrency: 8, discard: true }, ); + if (threadId === undefined) noPullRequestRechecks.retain(groups); }); const runSweep = ( diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index aa847174ac96..fc52191d32b2 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1839,6 +1839,154 @@ it.effect.each(["Stop", "Force stop"] as const)( }).pipe(Effect.provide(layerTest)), ); +// A finished delegated child whose native subagent the provider never reported ending, +// with no open turn item, and a nested native subagent on that agent's own thread. +it.effect( + "Stop settles a delegated row whose finished child still records a running native subagent", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const parentThreadId = ThreadId.make("thread:held-row"); + yield* createWatchingThread(parentThreadId, 30); + yield* send(parentThreadId, "parent", "start_immediately"); + const childThreadId = yield* delegate(parentThreadId, "held child"); + const child = yield* orchestrator.getThreadProjection(childThreadId); + const childRun = child.runs[0]!; + const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; + const now = yield* DateTime.now; + const nativeThreadId = ThreadId.make("thread:held-row-native"); + const nestedThreadId = ThreadId.make("thread:held-row-nested"); + yield* createWatchingThread(nativeThreadId, 31); + yield* createWatchingThread(nestedThreadId, 32); + const nativeProviderThreadId = ProviderThreadId.make("provider-thread:held-row-native"); + const runningRoot = (threadId: ThreadId, id: NodeId) => ({ + ...child.nodes.find((node) => node.id === childRun.rootNodeId)!, + id, + rootNodeId: id, + threadId, + runId: null, + parentNodeId: null, + status: "running" as const, + countsForRun: false, + completedAt: null, + }); + const nativeRoot = runningRoot(nativeThreadId, NodeId.make("node:held-row-native-root")); + const nestedRoot = runningRoot(nestedThreadId, NodeId.make("node:held-row-nested-root")); + for (const root of [nativeRoot, nestedRoot]) { + yield* projections.apply({ + id: EventId.make(`event:held-row:${root.id}`), + type: "node.updated", + threadId: root.threadId, + nodeId: root.id, + occurredAt: now, + payload: root, + }); + } + yield* projections.apply({ + id: EventId.make("event:held-row:native-turn"), + type: "provider-turn.updated", + threadId: nativeThreadId, + nodeId: nativeRoot.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make("turn:held-row-native"), + providerThreadId: nativeProviderThreadId, + nodeId: nativeRoot.id, + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + const native = { + ...task, + id: NodeId.make("node:held-row-native"), + threadId: childThreadId, + runId: childRun.id, + parentNodeId: childRun.rootNodeId!, + origin: "provider_native" as const, + providerThreadId: nativeProviderThreadId, + childThreadId: nativeThreadId, + status: "running" as const, + completionDelivery: undefined, + }; + yield* projections.apply({ + id: EventId.make("event:held-row:native"), + type: "subagent.updated", + threadId: childThreadId, + runId: childRun.id, + occurredAt: now, + payload: native, + }); + yield* projections.apply({ + id: EventId.make("event:held-row:nested"), + type: "subagent.updated", + threadId: nativeThreadId, + occurredAt: now, + payload: { + ...native, + id: NodeId.make("node:held-row-nested"), + threadId: nativeThreadId, + runId: null, + parentNodeId: nativeRoot.id, + childThreadId: nestedThreadId, + }, + }); + yield* projections.apply({ + id: EventId.make("event:held-row:child-completed"), + type: "run.updated", + threadId: childThreadId, + runId: childRun.id, + occurredAt: now, + payload: { ...childRun, status: "completed", startedAt: now, completedAt: now }, + }); + yield* orchestrator.recoverDelegatedTasks; + assert.equal( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]?.status, + "running", + ); + const statuses = Effect.gen(function* () { + const nativeAfter = yield* orchestrator.getThreadProjection(nativeThreadId); + const nestedAfter = yield* orchestrator.getThreadProjection(nestedThreadId); + return { + row: (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]?.status, + native: (yield* orchestrator.getThreadProjection(childThreadId)).subagents.find( + (subagent) => subagent.id === native.id, + )?.status, + nativeRoot: nativeAfter.nodes.find((node) => node.id === nativeRoot.id)?.status, + nested: nativeAfter.subagents[0]?.status, + nestedRoot: nestedAfter.nodes.find((node) => node.id === nestedRoot.id)?.status, + }; + }); + + // The row's Stop: the parent's task, then the child thread. + yield* orchestrator.dispatch({ + type: "subagent.stop", + commandId: CommandId.make("held-row:stop-task"), + threadId: parentThreadId, + runId: task.runId!, + subagentId: task.id, + }); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("held-row:stop-child"), + threadId: childThreadId, + }); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* worker.drain(); + assert.deepEqual(yield* statuses, { + row: "completed", + native: "interrupted", + nativeRoot: "interrupted", + nested: "interrupted", + nestedRoot: "interrupted", + }); + }).pipe(Effect.provide(layerTest)), +); + it.effect.each([ { followUp: "started its turn", startedBeforeRelease: true }, { followUp: "was promoted", startedBeforeRelease: false }, diff --git a/apps/server/src/orchestration-v2/noPullRequestRechecks.ts b/apps/server/src/orchestration-v2/noPullRequestRechecks.ts new file mode 100644 index 000000000000..763ece7bd9d4 --- /dev/null +++ b/apps/server/src/orchestration-v2/noPullRequestRechecks.ts @@ -0,0 +1,57 @@ +const RECHECK_MIN_MS = 30 * 60 * 1_000; +const RECHECK_MAX_MS = 24 * 60 * 60 * 1_000; +const RECHECK_IDLE_DIVISOR = 12; +const RECHECKS_PER_SWEEP = 4; + +/** + * Schedules a sweep's branch lookups, so their git work does not grow with the number of idle + * threads. GitManager's caches cannot: they expire in minutes and evict past a few thousand branches. + */ +export function makeNoPullRequestRechecks() { + const reads = new Map(); + return { + /** + * The branches a scheduled sweep looks up now: every one that last had a pull request, and + * the few longest unread that had none and are due. One idle a day is due every two hours. + */ + select: ( + nowMs: number, + branches: ReadonlyArray<{ readonly key: string; readonly activityAtMs: number }>, + ): ReadonlySet => { + const selected = new Set(); + const due: Array<{ key: string; activityAtMs: number; readAtMs: number }> = []; + for (const branch of branches) { + const read = reads.get(branch.key); + if (read?.found === true) { + selected.add(branch.key); + continue; + } + const delayMs = Math.min( + Math.max((nowMs - branch.activityAtMs) / RECHECK_IDLE_DIVISOR, RECHECK_MIN_MS), + RECHECK_MAX_MS, + ); + if (read !== undefined && nowMs - read.readAtMs < delayMs) continue; + due.push({ ...branch, readAtMs: read?.readAtMs ?? Number.NEGATIVE_INFINITY }); + } + due.sort((left, right) => + left.readAtMs === right.readAtMs + ? right.activityAtMs - left.activityAtMs + : left.readAtMs - right.readAtMs, + ); + // Stamped before the lookup, so one that keeps failing cannot hold a place in every sweep. + for (const { key } of due.slice(0, RECHECKS_PER_SWEEP)) { + selected.add(key); + reads.set(key, { readAtMs: nowMs, found: false }); + } + return selected; + }, + record: (key: string, nowMs: number, found: boolean): void => { + reads.set(key, { readAtMs: nowMs, found }); + }, + retain: (keys: { readonly has: (key: string) => boolean }): void => { + for (const key of reads.keys()) { + if (!keys.has(key)) reads.delete(key); + } + }, + }; +} diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index f5d8e5b01f36..3501f352fe69 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -8711,6 +8711,287 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([ + { ...modelSelection, model: "gpt-5.5", options: [{ id: "reasoningEffort", value: "high" }] }, + { instanceId: alternateInstanceId, model: "gpt-5.5" }, + ])( + "starts a queued scheduled delivery on a selection set while it waited: %j", + (newerSelection) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make( + `runtime-layer-queued-scheduled-selection-${newerSelection.instanceId}-${"options" in newerSelection}`, + ); + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Scheduled delivery selection", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + // Bound scheduled tasks send like ScheduledTaskService: no model of their own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + text: "Scheduled prompt", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + assert.equal(queued.status, "queued"); + assert.deepEqual(queued.modelSelection, modelSelection); + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-newer`), + threadId, + modelSelection: newerSelection, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + assert.deepEqual(delivered.modelSelection, newerSelection); + assert.equal( + after.attempts.find((attempt) => attempt.id === delivered.activeAttemptId) + ?.providerInstanceId, + newerSelection.instanceId, + ); + assert.deepEqual(after.thread.modelSelection, newerSelection); + assert.equal(after.thread.providerInstanceId, newerSelection.instanceId); + }), + ); + + it.effect( + "starts a queued scheduled delivery that pinned a model on its pin after the thread moved", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-queued-scheduled-pinned-selection"); + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + const pinnedSelection = { instanceId: modelSelection.instanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Pinned scheduled selection", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + // A task that pins a model names it on the delivery, as a fresh-thread launch seeds it. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + modelSelection: pinnedSelection, + text: "Pinned scheduled prompt", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + assert.equal(queued.status, "queued"); + assert.deepEqual(queued.modelSelection, pinnedSelection); + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); + // The user moves the thread off the pinned model while the delivery is held. + const movedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-moved`), + threadId, + modelSelection: movedSelection, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + // The pin wins: the run starts on the selection the delivery named, not the thread's later pick. + assert.deepEqual(delivered.modelSelection, pinnedSelection); + assert.equal( + after.attempts.find((attempt) => attempt.id === delivered.activeAttemptId) + ?.providerInstanceId, + pinnedSelection.instanceId, + ); + // Pinned tasks keep today's behaviour: the thread follows the run's selection back. + assert.deepEqual(after.thread.modelSelection, pinnedSelection); + }), + ); + + it.effect( + "starts a fresh scheduled delivery on a new provider thread after a provider switch", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-fresh-scheduled-provider-thread"); + const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Fresh scheduled provider thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + text: "Scheduled prompt", + attachments: [], + freshContext: true, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedProjection = yield* orchestrator.getThreadProjection(threadId); + const queued = queuedProjection.runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + // The queue reserved an unused provider thread on the original instance. + const reserved = queuedProjection.providerThreads.find( + (providerThread) => providerThread.id === queued.providerThreadId, + )!; + assert.equal(reserved.providerInstanceId, modelSelection.instanceId); + assert.isNull(reserved.firstRunOrdinal); + assert.isNull(reserved.nativeThreadRef); + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-switched`), + threadId, + modelSelection: switchedSelection, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; + + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + assert.isTrue(delivered.freshContext === true); + assert.notEqual(delivered.providerThreadId, reserved.id); + assert.equal( + after.providerThreads.find( + (providerThread) => providerThread.id === delivered.providerThreadId, + )?.providerInstanceId, + switchedSelection.instanceId, + ); + assert.equal( + after.providerThreads.find((providerThread) => providerThread.id === reserved.id) + ?.providerInstanceId, + modelSelection.instanceId, + ); + }), + ); + it.effect("keeps the queue after a user interrupts the active run", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -9508,8 +9789,8 @@ it.layer(layerSharedApplicationDataPlaneTest)("pending provider interruption", ( }); it.layer(layerTest)("snooze queued runs", (it) => { - it.effect.each(["notification", "delegated-completion", "user"] as const)( - "only user messages block snooze with a held %s run", + it.effect.each(["notification", "delegated-completion", "user", "limited-user"] as const)( + "only user messages block snooze with a queued %s run, unless a usage limit holds them", (kind) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -9578,8 +9859,8 @@ it.layer(layerTest)("snooze queued runs", (it) => { attachments: [], modelSelection, dispatchMode: { type: "queue_after_active" }, - createdBy: kind === "user" ? "user" : "agent", - creationSource: kind === "user" ? "web" : "server", + createdBy: kind === "user" || kind === "limited-user" ? "user" : "agent", + creationSource: kind === "user" || kind === "limited-user" ? "web" : "server", ...(kind === "notification" ? { notification: { @@ -9598,6 +9879,43 @@ it.layer(layerTest)("snooze queued runs", (it) => { } : {}), }); + if (kind === "limited-user") { + yield* eventSink.write({ + events: [ + { + id: EventId.make(`limited:${threadId}`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make(`limited:${threadId}`), + type: "error", + threadId, + runId: activeRun.id, + nodeId: activeRun.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Usage limit reached", + startedAt: now, + completedAt: now, + updatedAt: now, + // A proxied provider can report no reset time. + failure: { + class: "usage_limit", + message: "Claude API rate limit reached. Try again later.", + code: "api_error_429", + retryable: true, + resetAt: null, + }, + }, + }, + ], + }); + } const beforeRecovery = yield* orchestrator.getThreadProjection(threadId); yield* eventSink.commitCommand({ commandId: CommandId.make(`recovery:${threadId}`), @@ -9612,13 +9930,24 @@ it.layer(layerTest)("snooze queued runs", (it) => { occurredAt: now, payload: run.status === "queued" - ? { ...run, queueHeld: true } - : { ...run, status: "cancelled" as const, completedAt: now }, + ? // A usage limit stops the queue from starting without marking it held. + kind === "limited-user" + ? run + : { ...run, queueHeld: true } + : { + ...run, + status: kind === "limited-user" ? ("failed" as const) : ("cancelled" as const), + completedAt: now, + }, })), effects: [], }); const beforeSnooze = yield* orchestrator.getThreadProjection(threadId); - assert.isTrue(beforeSnooze.runs.some((run) => run.status === "queued" && run.queueHeld)); + assert.isTrue( + beforeSnooze.runs.some( + (run) => run.status === "queued" && (kind === "limited-user" || run.queueHeld), + ), + ); const snooze = orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make(`snooze:${threadId}`), @@ -10134,6 +10463,19 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { assert.isNotNull(visited.thread.lastVisitedAt); assert.equal(DateTime.formatIso(visited.thread.lastVisitedAt!), visitedAt); assert.deepEqual(visited.thread.updatedAt, createdUpdatedAt); + const sql = yield* SqlClient.SqlClient; + const visitPayloadKeys = (commandId: string) => + sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_events WHERE command_id = ${commandId} + `.pipe( + Effect.map((rows) => + rows.map((row) => Object.keys(JSON.parse(row.payload_json)).toSorted()), + ), + ); + // A visit stores read state, not a copy of the thread. + for (const keys of yield* visitPayloadKeys("runtime-layer-visited-thread-visit")) { + assert.includeMembers(["lastVisitedAt", "opens", "threadId"], keys); + } yield* orchestrator.dispatch({ type: "thread.visit", @@ -10143,6 +10485,8 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { }); const afterStaleVisit = yield* orchestrator.getThreadProjection(threadId); assert.equal(DateTime.formatIso(afterStaleVisit.thread.lastVisitedAt!), visitedAt); + // A stale visit with no open changes nothing, so it stores nothing. + assert.deepEqual(yield* visitPayloadKeys("runtime-layer-visited-thread-visit-stale"), []); const shell = yield* orchestrator.getShellSnapshot(); const thread = shell.threads.find((candidate) => candidate.id === threadId); @@ -10159,7 +10503,6 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { .pipe(Effect.flip); assert.instanceOf(markUnread, Orchestrator.OrchestratorDispatchError); - const sql = yield* SqlClient.SqlClient; yield* sql`INSERT INTO orchestration_v2_projection_turn_items ( turn_item_id, thread_id, ordinal, type, status, updated_at, payload_json ) VALUES ( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 0cfa599ed548..686663339716 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -47,6 +47,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic import * as ProviderSwitchService from "./ProviderSwitchService.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; import * as ProviderTurnStartService from "./ProviderTurnStartService.ts"; +import { layerThreadWorktreeRestore } from "../workspace/restoreMissingWorktree.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as ResourceCleanupService from "./ResourceCleanupService.ts"; @@ -267,6 +268,7 @@ const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImp layerLegacyV1ThreadImporterProvided, layerCommandReceiptStoreProvided, layerProviderSessionManagerProvided, + layerThreadWorktreeRestore, ), ), ); @@ -281,14 +283,13 @@ const layerManagedProjectFoldersProvided = ManagedProjectFolders.layer.pipe( const layerThreadLaunchProvided = ThreadLaunchService.layer.pipe( Layer.provide( Layer.mergeAll( - layerProjectService, layerProjectSetupScriptRunner, layerManagedProjectFoldersProvided, layerThreadManagementProvided, layerCommandReceiptStoreProvided, layerEventSink, IdAllocator.layer, - ), + ).pipe(Layer.provideMerge(layerProjectService)), ), ); const layerSecretRequestsProvided = SecretRequests.layer.pipe( @@ -307,11 +308,10 @@ const layerScheduledTaskProvided = ScheduledTaskService.layer.pipe( const layerScratchThreadPromotionProvided = ScratchThreadPromotion.layer.pipe( Layer.provide( Layer.mergeAll( - layerProjectService, layerManagedProjectFoldersProvided, layerThreadManagementProvided, layerScheduledTaskProvided, - ), + ).pipe(Layer.provideMerge(layerProjectService)), ), ); const layerProviderContinuationWorkerProvided = ProviderContinuationService.layer.pipe( @@ -389,8 +389,7 @@ export const layerProduction = Layer.mergeAll( WorktreeBranchSync.layer.pipe( Layer.provide(Layer.merge(layerThreadManagementProvided, ProjectionStore.layer)), ), - layer.pipe(Layer.provide(layerProjectService)), - layerProjectService, + layer, layerManagedProjectFoldersProvided, layerThreadLaunchProvided, layerScheduledTaskProvided, @@ -414,6 +413,7 @@ export const layerProduction = Layer.mergeAll( EffectOutbox.layerPruneWorker.pipe(Layer.provide(EffectOutbox.layer)), ).pipe( Layer.provide(ResourceCleanupService.layer.pipe(Layer.provide(layerScheduledTaskProvided))), + Layer.provideMerge(layerProjectService), Layer.provide(Scheduler.layer), Layer.provideMerge(layerEventInfrastructure), ); diff --git a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts index 8886daad3e01..1085fa04f756 100644 --- a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts @@ -95,6 +95,19 @@ describe("Claude Agent SDK replay fixtures", () => { userFacingDispatchErrorMessage(refused), "This subagent is run by its provider and cannot take messages. Message the parent thread instead.", ); + const refusedContinuation = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("command:subagent:continue-native-child"), + threadId: child.thread.id, + title: "Renamed", + continuation: { + messageId: MessageId.make("message:subagent:continue-native-child"), + text: "Also check the tests.", + }, + }) + .pipe(Effect.flip); + assert.equal(refusedContinuation._tag, "OrchestratorSubagentThreadReadOnlyError"); assert.equal(yield* orchestrator.getThreadEventSequence(child.thread.id), before); const after = yield* orchestrator.getThreadProjection(child.thread.id); assert.lengthOf(after.runs, 0); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 90207b34f104..965e5bd44f2d 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -2004,8 +2004,9 @@ describe("orchestration v2 provider switching", () => { yield* Deferred.await(started); yield* Fiber.join(toolCallRunning); - const runFailed = yield* awaitEvent( - (event) => event.type === "run.updated" && event.payload.status === "failed", + // The handoff itself is not a failure: the run completes once the session closes. + const runSettled = yield* awaitEvent( + (event) => event.type === "run.updated" && event.payload.status === "waiting", ); yield* orchestrator.dispatch({ type: "thread.metadata.update", @@ -2016,9 +2017,11 @@ describe("orchestration v2 provider switching", () => { expectedWorktreePath: null, }); yield* worker.drain(); - yield* Fiber.join(runFailed); + yield* Fiber.join(runSettled); + yield* worker.drain(); const projection = yield* orchestrator.getThreadProjection(scenarioThreadId); + assert.equal(projection.runs.at(-1)?.status, "completed"); const toolCall = projection.turnItems.find((item) => item.id === toolCallId); assert.equal(toolCall?.status, "interrupted"); assert.isNotNull(toolCall?.completedAt); @@ -3417,6 +3420,138 @@ describe("orchestration v2 provider switching", () => { }), ); + it.live("keeps the user's conversation after a scheduled fresh run in the same thread", () => + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("scheduled-fresh-user-chat"); + const capturedTurns = yield* Ref.make>([]); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + nativeThreadGeneration: yield* Ref.make(0), + }), + ]); + const threadId = ThreadId.make("thread:scheduled-fresh-user-chat"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const send = (name: string, scheduled: boolean) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:scheduled-fresh-user-chat:${name}`), + threadId, + messageId: MessageId.make(`message:scheduled-fresh-user-chat:${name}`), + text: name, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "queue_after_active" }, + ...(scheduled + ? { + scheduledTaskId: ScheduledTaskId.make("scheduled-fresh-user-chat"), + freshContext: true, + } + : {}), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:scheduled-fresh-user-chat:create"), + threadId, + projectId: ProjectId.make("project:scheduled-fresh-user-chat"), + title: "User chat with a fresh-session schedule", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const eventSink = yield* EventSink.EventSinkV2; + for (const [name, scheduled] of [ + ["user-first", false], + ["scheduled", true], + ["user-second", false], + ["compaction", false], + ["scheduled-after-compaction", true], + ["user-third", false], + ] as const) { + if (name === "compaction") { + // The user's run compacts, so later dispatches no longer read the first scheduled run's message. + const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; + const compactedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:scheduled-fresh-user-chat:compaction"), + type: "turn-item.updated", + threadId, + runId: run.id, + nodeId: run.rootNodeId!, + occurredAt: compactedAt, + payload: { + id: TurnItemId.make("item:scheduled-fresh-user-chat:compaction"), + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1_000, + status: "completed", + title: null, + startedAt: compactedAt, + completedAt: compactedAt, + updatedAt: compactedAt, + type: "compaction", + driver: CLAUDE_DRIVER, + }, + }, + ], + }); + continue; + } + yield* send(name, scheduled); + yield* worker.drain(); + yield* waitForIdle(threadId); + } + const turns = yield* Ref.get(capturedTurns); + const turnFor = (name: string) => turns.find((turn) => turn.text.endsWith(name)); + assert.notEqual( + turnFor("scheduled")!.nativeThreadId, + turnFor("user-first")!.nativeThreadId, + ); + // The scheduled run's fresh session must not replace the user's conversation. + assert.equal(turnFor("user-second")!.nativeThreadId, turnFor("user-first")!.nativeThreadId); + assert.equal(turnFor("user-third")!.nativeThreadId, turnFor("user-first")!.nativeThreadId); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "scheduled-fresh-user-chat", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ); + it.live("keeps the thread a queued fresh run reserved for that run", () => Effect.gen(function* () { const cwd = yield* checkpointWorkspace("fresh-context-reserved"); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index ce6d739b1cdd..1eae24c5bb2b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -1779,12 +1779,6 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray, HOME is /home/grok-replay and the recording user is grok-replay. T3-owned prompt text, MCP servers and initialize params are . Personal skills, machine identity, account settings and announcement broadcasts are removed, as are responses to Grok-internal request ids that T3's protocol drops. Timestamps are kept as recorded.","droppedFrames":5}} -{"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":"","clientInfo":"","_meta":{"clientType":"extension"}}}} -{"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"cached_token","name":"cached_token","description":"Cached token from ~/.grok/auth.json"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"cached_token","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"","agentVersion":"1.0.41","agentId":"00000000-0000-4000-8000-000000000000","agentInstanceId":"00000000-0000-4000-8000-000000000000","hostname":"grok-replay","modelState":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}}],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} -{"type":"emit_inbound","label":"notification:_x.ai/mcp/servers_updated","frame":{"kind":"notification","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"auth","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"resolve_workspace","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"folder_trust","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"plugin_registry","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"mcp_merge","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"persistence_init","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"spawn_session_actor","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"model_switch","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/mcp_initialized","frame":{"kind":"notification","method":"_x.ai/mcp_initialized","params":{"sessionId":"00000000-0000-4000-8000-000000000001","mcpToolCount":0,"elapsedMs":0}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"/home/grok-replay/.grok/bundled/workflows/deep-research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}},{"name":"build-with-ai","description":"Build AI apps on SpaceXAI (XAI_API_KEY + api.x.ai)","input":{"hint":""},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/build-with-ai/SKILL.md","bareName":"build-with-ai","qualifiedName":"bundled:build-with-ai"}},{"name":"code-review","description":"Run an extremely strict maintainability review for abstraction quality, giant files, and spaghetti-condition growth. Use for a deep code quality audit or an especially harsh maintainability review.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/code-review/SKILL.md","bareName":"code-review","qualifiedName":"bundled:code-review"}},{"name":"create-skill","description":"Create a new Grok skill","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-skill/SKILL.md","bareName":"create-skill","qualifiedName":"bundled:create-skill"}},{"name":"create-workflow","description":"Author a new multi-agent workflow","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-workflow/SKILL.md","bareName":"create-workflow","qualifiedName":"bundled:create-workflow"}},{"name":"execute-plan","description":"Execute a PR Plan DAG from a design document. Parses the plan, topologically sorts it, implements PRs in parallel using worktree-isolated subagents, runs mandatory orchestrator-level review, and assembles either a Graphite PR stack or a plain-git branch stack depending on tool availability.","input":{"hint":" [--effort N] [--concurrency N] [--dry-run] [--resume ] [--instructions \"...\"] [--no-graphite] [--auto-pr]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/execute-plan/SKILL.md","bareName":"execute-plan","qualifiedName":"bundled:execute-plan"}},{"name":"game-assets","description":"Engine-ready game art, 2D sprites to 3D models","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/game-assets/SKILL.md","bareName":"game-assets","qualifiedName":"bundled:game-assets"}},{"name":"bundled:imagine","description":"Prompting and workflow guidance for Imagine image and video tools","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/imagine/SKILL.md","bareName":"imagine","qualifiedName":"bundled:imagine"}},{"name":"implement","description":"Run the full implement-review-fix loop using implementer and reviewer personas. Supports effort-based multi-reviewer scaling (2–7 reviewers) with automatic specialization selection. Includes memory-based feedback loop that learns from past review patterns. Loops until all reviewers find 0 issues of any severity.","input":{"hint":"[--effort N] "},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/implement/SKILL.md","bareName":"implement","qualifiedName":"bundled:implement"}},{"name":"learn","description":"Learn from your own Grok Build sessions and tune your setup: add skills for things you keep typing, fix stale skill instructions, and retire skills, plugins, or MCP servers you never use. Use on \"/learn\", \"learn from my traces\", \"what skills do I never use\". Not for summarizing sessions into notes or memory.","input":{"hint":"[--mode step|auto|report] [--days N | --since-last | --limit N] [--per-trace] [--include-headless] [--cwd PATH] [--focus TEXT] [--resume]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/learn/SKILL.md","bareName":"learn","qualifiedName":"bundled:learn"}},{"name":"long-running-background-tasks","description":"Required reading before you start, watch, or wait on anything that keeps running after you launch it — background jobs, watchers, scheduled loops, CI, pull requests, training runs, dev servers, long builds. Read it before you launch such work and before you report on its state. Saying where a running job stands counts as working on it. Use when: about to launch, supervise, inspect, diagnose, or report on work that keeps running after it is started.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/long-running-background-tasks/SKILL.md","bareName":"long-running-background-tasks","qualifiedName":"bundled:long-running-background-tasks"}},{"name":"pr-babysit","description":"Monitor PRs, fix CI failures, address review comments (human and bot/automated), resolve merge conflicts, and restack stacks. Supports independent PRs, Graphite stacks, and GitHub stacked PRs (gh-stack). Optional ship lands a ready PR bottom-up when every job has passed and no comments remain open.","input":{"hint":"add [--ship] | remove | list | check | ship [on|off] [number...]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/pr-babysit/SKILL.md","bareName":"pr-babysit","qualifiedName":"bundled:pr-babysit"}},{"name":"resume-claude","description":"Continue from a recent Claude Code session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-claude/SKILL.md","bareName":"resume-claude","qualifiedName":"bundled:resume-claude"}},{"name":"resume-codex","description":"Continue from a recent Codex session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-codex/SKILL.md","bareName":"resume-codex","qualifiedName":"bundled:resume-codex"}},{"name":"resume-cursor","description":"Continue from a recent Cursor session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-cursor/SKILL.md","bareName":"resume-cursor","qualifiedName":"bundled:resume-cursor"}},{"name":"review","description":"Run a reviewer subagent against your whole branch vs main, your uncommitted working state, a named branch, a single GitHub PR, or an entire Graphite/stacked-PR stack (one reviewer subagent per PR). A bare /review defaults to reviewing your uncommitted working state when the tree is dirty, or your whole branch vs main when the tree is clean. Main, local, and branch modes write a review file plus a summary to disk. PR and stack modes post the findings as a PENDING GitHub review for the user to inspect and submit through the UI. Use --main for an explicit clean-tree branch review, --stack for stacked PRs, --submit to publish immediately as COMMENT, or --approve to publish and approve; the default remains PENDING.","input":{"hint":"[--main | --local | --branch | --pr | --stack | ] [--approve | --submit]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/review/SKILL.md","bareName":"review","qualifiedName":"bundled:review"}},{"name":"skill-design-principles","description":"Concise, high-signal principles for writing and editing skills well. Use whenever authoring or editing a skill.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/skill-design-principles/SKILL.md","bareName":"skill-design-principles","qualifiedName":"bundled:skill-design-principles"}},{"name":"statusline","description":"Configure the Grok Build status line.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/statusline/SKILL.md","bareName":"statusline","qualifiedName":"bundled:statusline"}},{"name":"threejs-frame-conventions","description":"One frame for Three.js controls, camera, and facing","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/threejs-frame-conventions/SKILL.md","bareName":"threejs-frame-conventions","qualifiedName":"bundled:threejs-frame-conventions"}},{"name":"learn-traces","description":"Workflow: Map-reduce a collected set of Grok Build sessions into a curated list of harness changes: new or edited skills, plugin enables, deletes","input":{"hint":"[--agent-budget N] [--effort LEVEL] [args]"},"_meta":{"workflowSource":"bundled","workflowPath":"/home/grok-replay/.grok/bundled/workflows/learn-traces.rhai"}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":2263,"eventId":"00000000-0000-4000-8000-000000000001-0","agentTimestampMs":1790300235225,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":60}}}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"/home/grok-replay/.grok/bundled/workflows/deep-research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}},{"name":"build-with-ai","description":"Build AI apps on SpaceXAI (XAI_API_KEY + api.x.ai)","input":{"hint":""},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/build-with-ai/SKILL.md","bareName":"build-with-ai","qualifiedName":"bundled:build-with-ai"}},{"name":"code-review","description":"Run an extremely strict maintainability review for abstraction quality, giant files, and spaghetti-condition growth. Use for a deep code quality audit or an especially harsh maintainability review.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/code-review/SKILL.md","bareName":"code-review","qualifiedName":"bundled:code-review"}},{"name":"create-skill","description":"Create a new Grok skill","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-skill/SKILL.md","bareName":"create-skill","qualifiedName":"bundled:create-skill"}},{"name":"create-workflow","description":"Author a new multi-agent workflow","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-workflow/SKILL.md","bareName":"create-workflow","qualifiedName":"bundled:create-workflow"}},{"name":"execute-plan","description":"Execute a PR Plan DAG from a design document. Parses the plan, topologically sorts it, implements PRs in parallel using worktree-isolated subagents, runs mandatory orchestrator-level review, and assembles either a Graphite PR stack or a plain-git branch stack depending on tool availability.","input":{"hint":" [--effort N] [--concurrency N] [--dry-run] [--resume ] [--instructions \"...\"] [--no-graphite] [--auto-pr]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/execute-plan/SKILL.md","bareName":"execute-plan","qualifiedName":"bundled:execute-plan"}},{"name":"game-assets","description":"Engine-ready game art, 2D sprites to 3D models","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/game-assets/SKILL.md","bareName":"game-assets","qualifiedName":"bundled:game-assets"}},{"name":"bundled:imagine","description":"Prompting and workflow guidance for Imagine image and video tools","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/imagine/SKILL.md","bareName":"imagine","qualifiedName":"bundled:imagine"}},{"name":"implement","description":"Run the full implement-review-fix loop using implementer and reviewer personas. Supports effort-based multi-reviewer scaling (2–7 reviewers) with automatic specialization selection. Includes memory-based feedback loop that learns from past review patterns. Loops until all reviewers find 0 issues of any severity.","input":{"hint":"[--effort N] "},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/implement/SKILL.md","bareName":"implement","qualifiedName":"bundled:implement"}},{"name":"learn","description":"Learn from your own Grok Build sessions and tune your setup: add skills for things you keep typing, fix stale skill instructions, and retire skills, plugins, or MCP servers you never use. Use on \"/learn\", \"learn from my traces\", \"what skills do I never use\". Not for summarizing sessions into notes or memory.","input":{"hint":"[--mode step|auto|report] [--days N | --since-last | --limit N] [--per-trace] [--include-headless] [--cwd PATH] [--focus TEXT] [--resume]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/learn/SKILL.md","bareName":"learn","qualifiedName":"bundled:learn"}},{"name":"long-running-background-tasks","description":"Required reading before you start, watch, or wait on anything that keeps running after you launch it — background jobs, watchers, scheduled loops, CI, pull requests, training runs, dev servers, long builds. Read it before you launch such work and before you report on its state. Saying where a running job stands counts as working on it. Use when: about to launch, supervise, inspect, diagnose, or report on work that keeps running after it is started.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/long-running-background-tasks/SKILL.md","bareName":"long-running-background-tasks","qualifiedName":"bundled:long-running-background-tasks"}},{"name":"pr-babysit","description":"Monitor PRs, fix CI failures, address review comments (human and bot/automated), resolve merge conflicts, and restack stacks. Supports independent PRs, Graphite stacks, and GitHub stacked PRs (gh-stack). Optional ship lands a ready PR bottom-up when every job has passed and no comments remain open.","input":{"hint":"add [--ship] | remove | list | check | ship [on|off] [number...]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/pr-babysit/SKILL.md","bareName":"pr-babysit","qualifiedName":"bundled:pr-babysit"}},{"name":"resume-claude","description":"Continue from a recent Claude Code session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-claude/SKILL.md","bareName":"resume-claude","qualifiedName":"bundled:resume-claude"}},{"name":"resume-codex","description":"Continue from a recent Codex session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-codex/SKILL.md","bareName":"resume-codex","qualifiedName":"bundled:resume-codex"}},{"name":"resume-cursor","description":"Continue from a recent Cursor session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-cursor/SKILL.md","bareName":"resume-cursor","qualifiedName":"bundled:resume-cursor"}},{"name":"review","description":"Run a reviewer subagent against your whole branch vs main, your uncommitted working state, a named branch, a single GitHub PR, or an entire Graphite/stacked-PR stack (one reviewer subagent per PR). A bare /review defaults to reviewing your uncommitted working state when the tree is dirty, or your whole branch vs main when the tree is clean. Main, local, and branch modes write a review file plus a summary to disk. PR and stack modes post the findings as a PENDING GitHub review for the user to inspect and submit through the UI. Use --main for an explicit clean-tree branch review, --stack for stacked PRs, --submit to publish immediately as COMMENT, or --approve to publish and approve; the default remains PENDING.","input":{"hint":"[--main | --local | --branch | --pr | --stack | ] [--approve | --submit]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/review/SKILL.md","bareName":"review","qualifiedName":"bundled:review"}},{"name":"skill-design-principles","description":"Concise, high-signal principles for writing and editing skills well. Use whenever authoring or editing a skill.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/skill-design-principles/SKILL.md","bareName":"skill-design-principles","qualifiedName":"bundled:skill-design-principles"}},{"name":"statusline","description":"Configure the Grok Build status line.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/statusline/SKILL.md","bareName":"statusline","qualifiedName":"bundled:statusline"}},{"name":"threejs-frame-conventions","description":"One frame for Three.js controls, camera, and facing","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/threejs-frame-conventions/SKILL.md","bareName":"threejs-frame-conventions","qualifiedName":"bundled:threejs-frame-conventions"}},{"name":"learn-traces","description":"Workflow: Map-reduce a collected set of Grok Build sessions into a curated list of harness changes: new or edited skills, plugin enables, deletes","input":{"hint":"[--agent-budget N] [--effort LEVEL] [args]"},"_meta":{"workflowSource":"bundled","workflowPath":"/home/grok-replay/.grok/bundled/workflows/learn-traces.rhai"}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":2263,"eventId":"00000000-0000-4000-8000-000000000001-1","agentTimestampMs":1790300235246,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":60}}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session_notification:model_changed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"model_changed","model_id":"grok-4.7","reasoning_effort":"high"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"git_discovery","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"finalize_response","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"tool_overrides","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"00000000-0000-4000-8000-000000000001","models":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.7","options":[{"value":"grok-4.7","name":"Grok 4.7"},{"value":"grok-4.7-build-fast","name":"Grok 4.7 Fast"},{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High","description":"Maximum reasoning for the hardest tasks."},{"value":"high","name":"High","description":"Thorough reasoning and quality. Recommended."},{"value":"medium","name":"Medium","description":"Strong quality with a faster turnaround."},{"value":"low","name":"Low","description":"Fastest responses. Best for simple tasks."}]}],"_meta":{"currentWorkingDirectory":"","codebaseIndexed":[],"isGitRepo":true,"gitRoot":"","showNonGitWarning":false,"feedbackEnabled":true,"x.ai/sessionConfig":{"options":[{"id":"grok-4.7","category":"model","label":"Grok 4.7","selected":true},{"id":"grok-4.7-build-fast","category":"model","label":"Grok 4.7 Fast","selected":false},{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":false},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","selected":false},{"id":"high","category":"mode","label":"High","description":"Thorough reasoning and quality. Recommended.","selected":true},{"id":"medium","category":"mode","label":"Medium","description":"Strong quality with a faster turnaround.","selected":false},{"id":"low","category":"mode","label":"Low","description":"Fastest responses. Best for simple tasks.","selected":false}]},"x.ai/sessionDetail":{"sessionId":"00000000-0000-4000-8000-000000000001","kind":"build","cwd":"","currentModelId":"grok-4.7"},"x.ai/memoryMode":"v2"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} -{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: steering fixture initial response\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235622,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} -{"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"00000000-0000-4000-8000-000000000001","_meta":{"cancelTrigger":"ctrl_c"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session_notification:turn_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"turn_completed","prompt_id":"t3-xai-prompt-1","stop_reason":"cancelled","elapsed_ms":22},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-4","agentTimestampMs":1790300235647,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/prompt_complete","frame":{"kind":"notification","method":"_x.ai/session/prompt_complete","params":{"sessionId":"00000000-0000-4000-8000-000000000001","promptId":"t3-xai-prompt-1","stopReason":"cancelled","agentResult":null,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"idle","resident":true,"lastChangeUnixMs":1790300235649,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":2263,"modelId":"grok-4.7","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"},"cancelTrigger":"ctrl_c"}}}} -{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nActually, respond with exactly: steering fixture observed\n"},{"type":"text","text":""}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235729,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} -{"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-8","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":1}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-9","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":2}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-10","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":3}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-11","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":4}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-12","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":5}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" respond"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-13","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":6}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-14","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":7}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-15","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":8}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-16","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":9}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-17","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":10}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ste"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-18","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":11}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ering"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-19","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":12}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fixture"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-20","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":13}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" observed"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-21","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":14}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-22","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":15}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ste"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-23","agentTimestampMs":1790300237880,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentMessageChunk","chunkId":16}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ering"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-24","agentTimestampMs":1790300237881,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentMessageChunk","chunkId":17}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" fixture"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-25","agentTimestampMs":1790300237940,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentMessageChunk","chunkId":18}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" observed"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-26","agentTimestampMs":1790300237940,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentMessageChunk","chunkId":19}}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"/home/grok-replay/.grok/bundled/workflows/deep-research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}},{"name":"build-with-ai","description":"Build AI apps on SpaceXAI (XAI_API_KEY + api.x.ai)","input":{"hint":""},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/build-with-ai/SKILL.md","bareName":"build-with-ai","qualifiedName":"bundled:build-with-ai"}},{"name":"code-review","description":"Run an extremely strict maintainability review for abstraction quality, giant files, and spaghetti-condition growth. Use for a deep code quality audit or an especially harsh maintainability review.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/code-review/SKILL.md","bareName":"code-review","qualifiedName":"bundled:code-review"}},{"name":"create-skill","description":"Create a new Grok skill","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-skill/SKILL.md","bareName":"create-skill","qualifiedName":"bundled:create-skill"}},{"name":"create-workflow","description":"Author a new multi-agent workflow","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-workflow/SKILL.md","bareName":"create-workflow","qualifiedName":"bundled:create-workflow"}},{"name":"execute-plan","description":"Execute a PR Plan DAG from a design document. Parses the plan, topologically sorts it, implements PRs in parallel using worktree-isolated subagents, runs mandatory orchestrator-level review, and assembles either a Graphite PR stack or a plain-git branch stack depending on tool availability.","input":{"hint":" [--effort N] [--concurrency N] [--dry-run] [--resume ] [--instructions \"...\"] [--no-graphite] [--auto-pr]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/execute-plan/SKILL.md","bareName":"execute-plan","qualifiedName":"bundled:execute-plan"}},{"name":"game-assets","description":"Engine-ready game art, 2D sprites to 3D models","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/game-assets/SKILL.md","bareName":"game-assets","qualifiedName":"bundled:game-assets"}},{"name":"bundled:imagine","description":"Prompting and workflow guidance for Imagine image and video tools","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/imagine/SKILL.md","bareName":"imagine","qualifiedName":"bundled:imagine"}},{"name":"implement","description":"Run the full implement-review-fix loop using implementer and reviewer personas. Supports effort-based multi-reviewer scaling (2–7 reviewers) with automatic specialization selection. Includes memory-based feedback loop that learns from past review patterns. Loops until all reviewers find 0 issues of any severity.","input":{"hint":"[--effort N] "},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/implement/SKILL.md","bareName":"implement","qualifiedName":"bundled:implement"}},{"name":"learn","description":"Learn from your own Grok Build sessions and tune your setup: add skills for things you keep typing, fix stale skill instructions, and retire skills, plugins, or MCP servers you never use. Use on \"/learn\", \"learn from my traces\", \"what skills do I never use\". Not for summarizing sessions into notes or memory.","input":{"hint":"[--mode step|auto|report] [--days N | --since-last | --limit N] [--per-trace] [--include-headless] [--cwd PATH] [--focus TEXT] [--resume]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/learn/SKILL.md","bareName":"learn","qualifiedName":"bundled:learn"}},{"name":"long-running-background-tasks","description":"Required reading before you start, watch, or wait on anything that keeps running after you launch it — background jobs, watchers, scheduled loops, CI, pull requests, training runs, dev servers, long builds. Read it before you launch such work and before you report on its state. Saying where a running job stands counts as working on it. Use when: about to launch, supervise, inspect, diagnose, or report on work that keeps running after it is started.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/long-running-background-tasks/SKILL.md","bareName":"long-running-background-tasks","qualifiedName":"bundled:long-running-background-tasks"}},{"name":"pr-babysit","description":"Monitor PRs, fix CI failures, address review comments (human and bot/automated), resolve merge conflicts, and restack stacks. Supports independent PRs, Graphite stacks, and GitHub stacked PRs (gh-stack). Optional ship lands a ready PR bottom-up when every job has passed and no comments remain open.","input":{"hint":"add [--ship] | remove | list | check | ship [on|off] [number...]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/pr-babysit/SKILL.md","bareName":"pr-babysit","qualifiedName":"bundled:pr-babysit"}},{"name":"resume-claude","description":"Continue from a recent Claude Code session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-claude/SKILL.md","bareName":"resume-claude","qualifiedName":"bundled:resume-claude"}},{"name":"resume-codex","description":"Continue from a recent Codex session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-codex/SKILL.md","bareName":"resume-codex","qualifiedName":"bundled:resume-codex"}},{"name":"resume-cursor","description":"Continue from a recent Cursor session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-cursor/SKILL.md","bareName":"resume-cursor","qualifiedName":"bundled:resume-cursor"}},{"name":"review","description":"Run a reviewer subagent against your whole branch vs main, your uncommitted working state, a named branch, a single GitHub PR, or an entire Graphite/stacked-PR stack (one reviewer subagent per PR). A bare /review defaults to reviewing your uncommitted working state when the tree is dirty, or your whole branch vs main when the tree is clean. Main, local, and branch modes write a review file plus a summary to disk. PR and stack modes post the findings as a PENDING GitHub review for the user to inspect and submit through the UI. Use --main for an explicit clean-tree branch review, --stack for stacked PRs, --submit to publish immediately as COMMENT, or --approve to publish and approve; the default remains PENDING.","input":{"hint":"[--main | --local | --branch | --pr | --stack | ] [--approve | --submit]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/review/SKILL.md","bareName":"review","qualifiedName":"bundled:review"}},{"name":"skill-design-principles","description":"Concise, high-signal principles for writing and editing skills well. Use whenever authoring or editing a skill.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/skill-design-principles/SKILL.md","bareName":"skill-design-principles","qualifiedName":"bundled:skill-design-principles"}},{"name":"statusline","description":"Configure the Grok Build status line.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/statusline/SKILL.md","bareName":"statusline","qualifiedName":"bundled:statusline"}},{"name":"threejs-frame-conventions","description":"One frame for Three.js controls, camera, and facing","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/threejs-frame-conventions/SKILL.md","bareName":"threejs-frame-conventions","qualifiedName":"bundled:threejs-frame-conventions"}},{"name":"learn-traces","description":"Workflow: Map-reduce a collected set of Grok Build sessions into a curated list of harness changes: new or edited skills, plugin enables, deletes","input":{"hint":"[--agent-budget N] [--effort LEVEL] [args]"},"_meta":{"workflowSource":"bundled","workflowPath":"/home/grok-replay/.grok/bundled/workflows/learn-traces.rhai"}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":22482,"eventId":"00000000-0000-4000-8000-000000000001-27","agentTimestampMs":1790300238036,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":60}}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session_notification:response_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":20630,"output_tokens":52,"cache_read_input_tokens":1792,"cache_creation_input_tokens":0,"reasoning_tokens":48},"signature":"cNT0TTliyhDVuYf3s5VZfOfJbZ45UY6L5HBO+Xb/gBknGuT6PJ2VPHHFehMK0YlGQp0HdgesFPfH+4r5wgsonXfDZrP6QRgdIc6uzdd/tZRCuYdQaXYeuQok7T7Zy7IJip8ts9EFpP6IESqUwU/SDd2eUnc/trneRbDUTjoksGYLl3d3YCGz+1sK6fSy00huVXUpqABRPOcMn/gK6qtQVneVkGMYW/wD5Wvxp3OGC/LCwu9PT4WUYQtoPOi5ObKcFdKPj5U9y6HssWazr6kSfqt1ox2f7z5Dq/Iu57NxLuJEDZt99A/1Qo1i3DbUuUet6jEL7JkCcBwsZrBKcAu5d8wk7xV8D6rUNtpXpr0XPCZMUQLFUPL2tg"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/session_notification:turn_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"turn_completed","prompt_id":"t3-xai-prompt-2","stop_reason":"end_turn","usage":{"inputTokens":22422,"outputTokens":52,"totalTokens":22474,"cachedReadTokens":1792,"cacheCreationTokens":0,"reasoningTokens":48,"modelCalls":1,"apiDurationMs":2245,"costUsdTicks":144391200,"modelUsage":{"grok-4.7-build":{"inputTokens":22422,"outputTokens":52,"totalTokens":22474,"cachedReadTokens":1792,"cacheCreationTokens":0,"reasoningTokens":48,"modelCalls":1,"apiDurationMs":2245,"costUsdTicks":144391200}},"numTurns":1},"elapsed_ms":2358},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-30","agentTimestampMs":1790300238089}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/prompt_complete","frame":{"kind":"notification","method":"_x.ai/session/prompt_complete","params":{"sessionId":"00000000-0000-4000-8000-000000000001","promptId":"t3-xai-prompt-2","stopReason":"end_turn","agentResult":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"idle","resident":true,"lastChangeUnixMs":1790300238090,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-2","promptId":"t3-xai-prompt-2","totalTokens":22482,"modelId":"grok-4.7","inputTokens":22422,"outputTokens":52,"cachedReadTokens":1792,"reasoningTokens":48,"usage":{"inputTokens":22422,"outputTokens":52,"totalTokens":22474,"cachedReadTokens":1792,"cacheCreationTokens":0,"reasoningTokens":48,"modelCalls":1,"apiDurationMs":2245,"costUsdTicks":144391200,"modelUsage":{"grok-4.7-build":{"inputTokens":22422,"outputTokens":52,"totalTokens":22474,"cachedReadTokens":1792,"cacheCreationTokens":0,"reasoningTokens":48,"modelCalls":1,"apiDurationMs":2245,"costUsdTicks":144391200}},"numTurns":1}}}}} -{"type":"expect_outbound","label":"request:session/close","frame":{"kind":"request","method":"session/close","params":{"sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[],"removed":["00000000-0000-4000-8000-000000000001"]}}} -{"type":"emit_inbound","label":"response:session/close","frame":{"kind":"response","method":"session/close","result":{"_meta":{"x.ai/closeOutcome":"closed"}}}} -{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 3c0e2652ea48..84fe35c6f21f 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -95,6 +95,7 @@ const boxThreads = (seen: Ref.Ref, linkSession: Ref.Ref) => { getProjectThreadRecords: (input) => projectionOf(input.threadId), getThreadRecords: (threadId) => projectionOf(threadId), listProjectThreads: () => linked.pipe(Effect.map((shell) => [shell] as never)), + getShellSnapshot: () => linked.pipe(Effect.map((shell) => ({ threads: [shell] }) as never)), sendToThread: (input) => Ref.update(seen, (current) => ({ ...current, sends: [...current.sends, input] })).pipe( Effect.as({ run: run("running"), delivery: "started" } as never), diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index d29a33c1119b..fcb682ab03fa 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -565,6 +565,37 @@ it.effect("a linked peer that is renamed shows its new name", () => ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect( + "a link forgotten while it resolves says it is not linked, not that it lost its credential", + () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const beforeGet = yield* Ref.make>(Effect.void); + const racing = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.make, (store) => + ServerSecretStore.ServerSecretStore.of({ + ...store, + get: (name) => + Ref.getAndSet(beforeGet, Effect.void).pipe( + Effect.flatten, + Effect.andThen(store.get(name)), + ), + }), + ), + ); + const a = yield* makeA(laptop, racing); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + + yield* Ref.set(beforeGet, Effect.orDie(Effect.asVoid(a.links.unlink(box.environmentId)))); + const forgotten = yield* probe(a).pipe(Effect.flip); + expect(forgotten.message).toContain("not linked to"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("tells the agent to link again once the peer revokes the link or it expires", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index b9c70127971b..2fa08d5b4367 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -635,13 +635,12 @@ const make = Effect.gen(function* () { const resolve: PeerLinks["Service"]["resolve"] = Effect.fn("PeerLinks.resolve")( function* (environmentId) { + const notLinked = new PeerLinkError({ + reason: "unknown_link", + message: `This environment is not linked to ${environmentId}. t3_environment_links lists the linked ones.`, + }); const row = yield* readRow(environmentId); - if (Option.isNone(row)) { - return yield* new PeerLinkError({ - reason: "unknown_link", - message: `This environment is not linked to ${environmentId}. t3_environment_links lists the linked ones.`, - }); - } + if (Option.isNone(row)) return yield* notLinked; const link = yield* toLink(row.value); if (DateTime.isLessThanOrEqualTo(link.expiresAt, yield* DateTime.now)) { return yield* new PeerLinkError({ @@ -651,6 +650,8 @@ const make = Effect.gen(function* () { } const stored = yield* secrets.get(row.value.secret_name).pipe(Effect.catch(failStorage)); if (Option.isNone(stored)) { + // An unlink between the two reads removes the row before its secret. + if (Option.isNone(yield* readRow(environmentId))) return yield* notLinked; return yield* new PeerLinkError({ reason: "unknown_link", message: `The link to ${link.label} lost its credential. Link it again.`, diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 6d09b6b1b3a6..af569e902c02 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -25,6 +25,7 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -644,6 +645,8 @@ const makeLaptop = ( >, /** The laptop's links from before a restart, which keep their sessions there. */ kept?: Context.Context>>, + /** Runs as each follow attempt here starts, on its follower's fiber. */ + beforeFollow: Effect.Effect = Effect.void, ) => Effect.gen(function* () { const linking = kept ?? (yield* layerLinkingEnvironment(laptop).pipe(Layer.build)); @@ -672,6 +675,19 @@ const makeLaptop = ( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provideMerge( RemoteDelegation.layer.pipe( + Layer.provide( + Layer.effect( + ThreadManagement.ThreadManagementService, + Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + return { + ...threads, + getThreadEventSequence: (threadId: ThreadId) => + beforeFollow.pipe(Effect.andThen(threads.getThreadEventSequence(threadId))), + }; + }), + ), + ), Layer.provideMerge(PeerForwarding.layer), Layer.provide( Layer.mock(ProjectService.ProjectService)({ @@ -1784,6 +1800,108 @@ const steppedRetries = Effect.gen(function* () { return { failed, retry, schedule }; }); +it.effect("a follower that keeps hitting defects fails the task and wakes the parent", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const retries = yield* steppedRetries; + const attempts = yield* Ref.make(0); + // The defect carries internal detail that the task's result must not. + const beforeFollow = Ref.update(attempts, (count) => count + 1).pipe( + Effect.andThen(Effect.die(new Error("A follow attempt hit a bug: defect-detail-4f1c9e"))), + ); + const a = yield* makeLaptop(database, offers, undefined, beforeFollow).pipe( + Effect.provideService(RemoteDelegation.FollowRetrySchedule, retries.schedule), + ); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + yield* Queue.take(retries.failed).pipe( + Effect.andThen(Queue.offer(retries.retry, undefined)), + Effect.forever, + Effect.forkScoped, + ); + + const delegated = yield* delegateToBox(a); + const task = yield* taskResult(a, delegated.taskId); + expect(yield* Ref.get(attempts)).toBe(3); + expect(task?.status).toBe("failed"); + expect(task?.result).toContain(delegated.remoteChild.threadId); + expect(task?.result).not.toContain("defect-detail-4f1c9e"); + const offered = yield* SubscriptionRef.changes(offers).pipe( + Stream.filter((seen) => seen.some((offer) => offer.threadId === parentThreadId)), + Stream.runHead, + ); + expect(Option.isSome(offered)).toBe(true); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect.each(["a still-running wait", "a failed attempt"] as const)( + "defects separated by %s do not add up to giving up", + (between) => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const retries = yield* steppedRetries; + const attempts = yield* Ref.make(0); + const defect = Effect.die(new Error("A follow attempt hit a bug.")); + const script = [ + defect, + between === "a still-running wait" + ? Effect.void + : Effect.fail( + new Orchestrator.OrchestratorProjectionError({ threadId: parentThreadId }), + ), + defect, + between === "a still-running wait" + ? Effect.void + : Effect.fail( + new Orchestrator.OrchestratorProjectionError({ threadId: parentThreadId }), + ), + defect, + ]; + // Past the script, the run there has ended, so the next attempt completes the task. + const beforeFollow = Ref.getAndUpdate(attempts, (count) => count + 1).pipe( + Effect.flatMap( + (count) => + script[count] ?? + Ref.set(thread.reply, "All green.").pipe( + Effect.andThen(Ref.set(thread.status, "completed")), + ), + ), + ); + // Each wait there answers at once that its run is still running. + yield* Deferred.succeed(thread.finished, undefined); + const a = yield* makeLaptop(database, offers, undefined, beforeFollow).pipe( + Effect.provideService(RemoteDelegation.FollowRetrySchedule, retries.schedule), + ); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + yield* Queue.take(retries.failed).pipe( + Effect.andThen(Queue.offer(retries.retry, undefined)), + Effect.forever, + Effect.forkScoped, + ); + + const delegated = yield* delegateToBox(a); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "All green.", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + /** Waits until the task here records that the `stopCount`th Stop after its result reached there. */ const stoppedThere = ( laptopEnv: Effect.Success>, @@ -1813,6 +1931,87 @@ const stoppedThere = ( ), ); +it.effect("a follower that keeps hitting defects leaves a stop it owes owed", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const retries = yield* steppedRetries; + // Follow attempts hit a defect only once the task has its result. + const broken = yield* Ref.make(false); + const follower = yield* Deferred.make>(); + const beforeFollow = Ref.get(broken).pipe( + Effect.flatMap((isBroken) => + isBroken + ? Effect.withFiber((fiber) => Deferred.succeed(follower, fiber)).pipe( + Effect.andThen(Effect.die(new Error("A follow attempt hit a bug."))), + ) + : Effect.void, + ), + ); + const a = yield* makeLaptop(database, offers, undefined, beforeFollow).pipe( + Effect.provideService(RemoteDelegation.FollowRetrySchedule, retries.schedule), + ); + yield* a.remote.start(); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + yield* Ref.set(thread.reply, "All green."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + yield* taskResult(a, delegated.taskId); + yield* Ref.set(broken, true); + yield* Queue.take(retries.failed).pipe( + Effect.andThen(Queue.offer(retries.retry, undefined)), + Effect.forever, + Effect.forkScoped, + ); + + // The Stop owes one stop there, which every attempt to send fails on. + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent-defective-follower"), + }); + const owedAgain = a.orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: 0, + eventType: "subagent.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === delegated.taskId && + (stored.event.payload.remoteChild?.stopCount ?? 0) > 1, + ), + Stream.runHead, + Effect.flatMap((owed) => + Option.isSome(owed) ? Effect.succeed("owed another stop" as const) : Effect.never, + ), + ); + const ended = yield* Deferred.await(follower).pipe( + Effect.flatMap(Fiber.await), + Effect.as("follower ended" as const), + Effect.raceFirst(owedAgain), + ); + expect(ended).toBe("follower ended"); + const { subagents } = yield* a.threads.getThreadRecords(parentThreadId, ["subagents"], { + subagentIds: [delegated.taskId], + }); + expect(subagents[0]).toMatchObject({ + status: "completed", + result: "All green.", + remoteChild: { stoppedThere: false, stopCount: 1 }, + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("stopping the parent stops follow-up work on a task there that has its result", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 3b01c48b01db..c9e38b4bc73d 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -19,6 +19,7 @@ import { type RunId, type ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; @@ -53,6 +54,8 @@ import * as PeerLinks from "./PeerLinks.ts"; const FOLLOW_WAIT_MS = 10 * 60 * 1_000; /** A linked environment that stops answering is retried, backing off to this. */ const FOLLOW_MAX_BACKOFF = Duration.minutes(5); +/** A follower that hits this many defects in a row fails its task instead of retrying. */ +const FOLLOW_MAX_DEFECTS = 3; /** How a follower retries; tests step it by hand. */ export const FollowRetrySchedule = Context.Reference>( "t3/peer/RemoteDelegation/FollowRetrySchedule", @@ -673,26 +676,54 @@ const make = Effect.gen(function* () { /** * Waits for the task's run there to end, completes the task here, and stops it there if it * ended here first. A revoked, expired or forgotten link, or a thread or run gone there, - * fails the task with nothing left to stop; anything else (the other machine asleep, a - * dropped route) is retried with back-off, and so is completing. + * fails the task with nothing left to stop; repeated defects here fail it too. Anything else + * (the other machine asleep, a dropped route) is retried with back-off, and so is completing. */ const follow = (parentThreadId: ThreadId, taskId: NodeId): Effect.Effect => - followOnce(parentThreadId, taskId).pipe( - // `false` means it is still running there: follow again straight away. - Effect.repeat({ while: (done) => !done }), - Effect.catchTags({ - OrchestratorMcpFailure: Effect.fn("RemoteDelegation.followFailed")(function* (error) { - const reason = yield* finalReason(parentThreadId, taskId, error); - if (reason === undefined) return yield* error; - // Gone there, or no link to reach it by: nothing left to stop there. - const { task } = yield* readTask(parentThreadId, taskId); - const stopCount = task?.remoteChild?.stopCount; - yield* complete(parentThreadId, taskId, "failed", reason, "stopped", stopCount); + Effect.suspend(() => { + let defects = 0; + const noDefect = Effect.sync(() => { + defects = 0; + }); + return followOnce(parentThreadId, taskId).pipe( + Effect.tap(noDefect), + // `false` means it is still running there: follow again straight away. + Effect.repeat({ while: (done) => !done }), + Effect.catchTags({ + OrchestratorMcpFailure: Effect.fn("RemoteDelegation.followFailed")(function* (error) { + const reason = yield* finalReason(parentThreadId, taskId, error); + if (reason === undefined) return yield* error; + // Gone there, or no link to reach it by: nothing left to stop there. + const { task } = yield* readTask(parentThreadId, taskId); + const stopCount = task?.remoteChild?.stopCount; + yield* complete(parentThreadId, taskId, "failed", reason, "stopped", stopCount); + }), }), - }), - Effect.retry(followRetry), - Effect.ignoreCause({ log: true }), - ); + Effect.tapError(() => noDefect), + Effect.tapDefect(() => + Effect.sync(() => { + defects += 1; + }), + ), + failOnDefect, + Effect.retry({ schedule: followRetry, while: () => defects < FOLLOW_MAX_DEFECTS }), + Effect.catch( + Effect.fn("RemoteDelegation.followGaveUp")( + function* () { + const { task } = yield* readTask(parentThreadId, taskId); + const remote = task?.remoteChild; + // A task with its result keeps it; completing it again would owe another stop there. + if (remote === undefined || task?.result !== null) return; + const result = `Following the thread ${remote.threadId} in ${remote.label} failed repeatedly, so it may still run there (task_cancel stops it).`; + yield* complete(parentThreadId, taskId, "failed", result, undefined); + }, + failOnDefect, + Effect.retry(followRetry), + ), + ), + Effect.ignoreCause({ log: true }), + ); + }); /** * One wait on the task's thread there; true once the task here is done. @@ -976,6 +1007,14 @@ const make = Effect.gen(function* () { return RemoteDelegation.of({ delegate, followUp, cancel, start }); }); +/** A defect, logged, as a failure, so a retry covers it. */ +const failOnDefect = (effect: Effect.Effect) => + Effect.catchDefect(effect, (defect) => + Effect.logWarning("A remote task's follower hit a defect", Cause.die(defect)).pipe( + Effect.andThen(Effect.fail(new Cause.UnknownError(defect, "A follower hit a defect."))), + ), + ); + const isTerminal = (status: string) => status === "completed" || status === "failed" || diff --git a/apps/server/src/persistence/AuthConnectDevices.ts b/apps/server/src/persistence/AuthConnectDevices.ts new file mode 100644 index 000000000000..52ec1039f018 --- /dev/null +++ b/apps/server/src/persistence/AuthConnectDevices.ts @@ -0,0 +1,160 @@ +import * as Context from "effect/Context"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlSchema from "effect/sql/SqlSchema"; + +import { AuthClientMetadata } from "@t3tools/contracts"; + +import { type AuthPairingLinkRepositoryError, PersistenceSqlError } from "./Errors.ts"; + +export const AuthConnectDeviceRecord = Schema.Struct({ + proofKeyThumbprint: Schema.String, + client: Schema.NullOr(Schema.fromJsonString(AuthClientMetadata)), + approvedAt: Schema.DateTimeUtcFromString, +}); +export type AuthConnectDeviceRecord = typeof AuthConnectDeviceRecord.Type; + +const TrustInput = Schema.Struct({ + proofKeyThumbprint: Schema.String, + client: Schema.NullOr(Schema.fromJsonString(AuthClientMetadata)), + approvedAt: Schema.DateTimeUtcFromString, +}); + +const ThumbprintInput = Schema.Struct({ proofKeyThumbprint: Schema.String }); + +const RemoveAllExceptInput = Schema.Struct({ + keepProofKeyThumbprint: Schema.NullOr(Schema.String), +}); + +/** T3 Connect device keys this environment lets in without asking. A key leaves when revoked. */ +export class AuthConnectDeviceRepository extends Context.Service< + AuthConnectDeviceRepository, + { + /** Adds the key, or refreshes how an already trusted key describes itself. */ + readonly trust: ( + input: typeof TrustInput.Type, + ) => Effect.Effect; + readonly isTrusted: ( + proofKeyThumbprint: string, + ) => Effect.Effect; + readonly list: () => Effect.Effect< + ReadonlyArray, + AuthPairingLinkRepositoryError + >; + readonly remove: ( + proofKeyThumbprint: string, + ) => Effect.Effect; + readonly removeAllExcept: ( + keepProofKeyThumbprint: string | null, + ) => Effect.Effect; + } +>()("t3/persistence/AuthConnectDevices/AuthConnectDeviceRepository") {} + +const sqlError = (operation: string) => (cause: unknown) => + new PersistenceSqlError({ operation, cause }); + +/** @public */ +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const trustRow = SqlSchema.void({ + Request: TrustInput, + execute: ({ proofKeyThumbprint, client, approvedAt }) => + sql` + INSERT INTO auth_connect_devices (proof_key_thumbprint, client_json, approved_at) + VALUES (${proofKeyThumbprint}, ${client}, ${approvedAt}) + ON CONFLICT (proof_key_thumbprint) DO UPDATE + SET client_json = COALESCE(excluded.client_json, auth_connect_devices.client_json) + `, + }); + + const isTrustedRow = SqlSchema.findAll({ + Request: ThumbprintInput, + Result: Schema.Struct({ proofKeyThumbprint: Schema.String }), + execute: ({ proofKeyThumbprint }) => + sql` + SELECT proof_key_thumbprint AS "proofKeyThumbprint" + FROM auth_connect_devices + WHERE proof_key_thumbprint = ${proofKeyThumbprint} + `, + }); + + const listRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: AuthConnectDeviceRecord, + execute: () => + sql` + SELECT + proof_key_thumbprint AS "proofKeyThumbprint", + client_json AS "client", + approved_at AS "approvedAt" + FROM auth_connect_devices + ORDER BY approved_at DESC, proof_key_thumbprint + `, + }); + + const removeRows = SqlSchema.findAll({ + Request: ThumbprintInput, + Result: Schema.Struct({ proofKeyThumbprint: Schema.String }), + execute: ({ proofKeyThumbprint }) => + sql` + DELETE FROM auth_connect_devices + WHERE proof_key_thumbprint = ${proofKeyThumbprint} + RETURNING proof_key_thumbprint AS "proofKeyThumbprint" + `, + }); + + const removeAllExceptRows = SqlSchema.findAll({ + Request: RemoveAllExceptInput, + Result: Schema.Struct({ proofKeyThumbprint: Schema.String }), + execute: ({ keepProofKeyThumbprint }) => + sql` + DELETE FROM auth_connect_devices + WHERE proof_key_thumbprint IS NOT ${keepProofKeyThumbprint} + RETURNING proof_key_thumbprint AS "proofKeyThumbprint" + `, + }); + + const trust: AuthConnectDeviceRepository["Service"]["trust"] = (input) => + trustRow(input).pipe(Effect.mapError(sqlError("AuthConnectDeviceRepository.trust"))); + + const isTrusted: AuthConnectDeviceRepository["Service"]["isTrusted"] = (proofKeyThumbprint) => + isTrustedRow({ proofKeyThumbprint }).pipe( + NodeSqliteClient.readOnly, + Effect.map((rows) => rows.length > 0), + Effect.mapError(sqlError("AuthConnectDeviceRepository.isTrusted")), + ); + + const list: AuthConnectDeviceRepository["Service"]["list"] = () => + listRows(undefined).pipe( + NodeSqliteClient.readOnly, + Effect.mapError(sqlError("AuthConnectDeviceRepository.list")), + ); + + const remove: AuthConnectDeviceRepository["Service"]["remove"] = (proofKeyThumbprint) => + removeRows({ proofKeyThumbprint }).pipe( + Effect.map((rows) => rows.length > 0), + Effect.mapError(sqlError("AuthConnectDeviceRepository.remove")), + ); + + const removeAllExcept: AuthConnectDeviceRepository["Service"]["removeAllExcept"] = ( + keepProofKeyThumbprint, + ) => + removeAllExceptRows({ keepProofKeyThumbprint }).pipe( + Effect.map((rows) => rows.length), + Effect.mapError(sqlError("AuthConnectDeviceRepository.removeAllExcept")), + ); + + return { + trust, + isTrusted, + list, + remove, + removeAllExcept, + } satisfies AuthConnectDeviceRepository["Service"]; +}); + +export const layer = Layer.effect(AuthConnectDeviceRepository, make); diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index 52323f9690af..df513f9963ca 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlSchema from "effect/sql/SqlSchema"; -import { AuthEnvironmentScopes } from "@t3tools/contracts"; +import { AuthClientMetadata, AuthEnvironmentScopes } from "@t3tools/contracts"; import { type AuthPairingLinkRepositoryError, @@ -22,6 +22,8 @@ import { */ export const MCP_APPROVAL_SUBJECT_PREFIX = "mcp-approval:"; +const SqliteFlag = Schema.Literals([0, 1]); + export const AuthPairingLinkRecord = Schema.Struct({ id: Schema.String, credential: Schema.String, @@ -34,6 +36,10 @@ export const AuthPairingLinkRecord = Schema.Struct({ expiresAt: Schema.DateTimeUtcFromString, consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + /** Spendable only once `proofKeyThumbprint` is a trusted T3 Connect device. */ + requiresConnectApproval: SqliteFlag, + connectApproved: SqliteFlag, + requestedClient: Schema.NullOr(Schema.fromJsonString(AuthClientMetadata)), }); export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; @@ -45,6 +51,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ subject: Schema.String, label: Schema.NullOr(Schema.String), proofKeyThumbprint: Schema.NullOr(Schema.String), + requiresConnectApproval: Schema.optionalKey(Schema.Boolean), createdAt: Schema.DateTimeUtcFromString, expiresAt: Schema.DateTimeUtcFromString, }); @@ -78,6 +85,32 @@ export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ }); export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; +export const GetActiveAuthPairingLinkByIdInput = Schema.Struct({ + id: Schema.String, + now: Schema.DateTimeUtcFromString, +}); +export type GetActiveAuthPairingLinkByIdInput = typeof GetActiveAuthPairingLinkByIdInput.Type; + +export const FindPendingConnectPairingLinkInput = Schema.Struct({ + proofKeyThumbprint: Schema.String, + now: Schema.DateTimeUtcFromString, +}); +export type FindPendingConnectPairingLinkInput = typeof FindPendingConnectPairingLinkInput.Type; + +/** Unspent links bound to `proofKeyThumbprint`, or with `keepProofKeyThumbprint` every other bound one. */ +export const RevokeUnspentByProofKeyInput = Schema.Struct({ + proofKeyThumbprint: Schema.NullOr(Schema.String), + keepProofKeyThumbprint: Schema.NullOr(Schema.String), + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeUnspentByProofKeyInput = typeof RevokeUnspentByProofKeyInput.Type; + +export const RecordConnectRequestClientInput = Schema.Struct({ + id: Schema.String, + client: Schema.fromJsonString(AuthClientMetadata), +}); +export type RecordConnectRequestClientInput = typeof RecordConnectRequestClientInput.Type; + const AuthPairingLinkRawDbRow = Schema.Struct({ id: Schema.String, credential: Schema.Unknown, @@ -90,6 +123,9 @@ const AuthPairingLinkRawDbRow = Schema.Struct({ expiresAt: Schema.Unknown, consumedAt: Schema.Unknown, revokedAt: Schema.Unknown, + requiresConnectApproval: Schema.Unknown, + connectApproved: Schema.Unknown, + requestedClient: Schema.Unknown, }); const decodeAuthPairingLinkDbRow = Schema.decodeUnknownEffect(AuthPairingLinkRecord); @@ -112,6 +148,20 @@ export class AuthPairingLinkRepository extends Context.Service< readonly getByCredential: ( input: GetAuthPairingLinkByCredentialInput, ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly getActiveById: ( + input: GetActiveAuthPairingLinkByIdInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + /** The device's open or denied request, so its retries do not stack up or re-prompt. */ + readonly findPendingConnect: ( + input: FindPendingConnectPairingLinkInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + /** Keeps the first description a device gave, so it cannot be swapped while someone decides. */ + readonly recordConnectRequestClient: ( + input: RecordConnectRequestClientInput, + ) => Effect.Effect; + readonly revokeUnspentByProofKey: ( + input: RevokeUnspentByProofKeyInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; } >()("t3/persistence/AuthPairingLinks/AuthPairingLinkRepository") {} @@ -146,6 +196,7 @@ export const make = Effect.gen(function* () { subject, label, proof_key_thumbprint, + requires_connect_approval, created_at, expires_at, consumed_at, @@ -159,6 +210,7 @@ export const make = Effect.gen(function* () { ${input.subject}, ${input.label}, ${input.proofKeyThumbprint}, + ${input.requiresConnectApproval ? 1 : 0}, ${input.createdAt}, ${input.expiresAt}, NULL, @@ -194,6 +246,10 @@ export const make = Effect.gen(function* () { proof_key_thumbprint IS NULL OR proof_key_thumbprint = ${proofKeyThumbprint} ) + AND ( + requires_connect_approval = 0 + OR proof_key_thumbprint IN (SELECT proof_key_thumbprint FROM auth_connect_devices) + ) AND (${requestedScopes === undefined ? 1 : 0} OR EXISTS ( SELECT 1 FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested @@ -219,7 +275,13 @@ export const make = Effect.gen(function* () { created_at AS "createdAt", expires_at AS "expiresAt", consumed_at AS "consumedAt", - revoked_at AS "revokedAt" + revoked_at AS "revokedAt", + requires_connect_approval AS "requiresConnectApproval", + EXISTS ( + SELECT 1 FROM auth_connect_devices + WHERE auth_connect_devices.proof_key_thumbprint = auth_pairing_links.proof_key_thumbprint + ) AS "connectApproved", + requested_client_json AS "requestedClient" `, }); @@ -239,7 +301,13 @@ export const make = Effect.gen(function* () { created_at AS "createdAt", expires_at AS "expiresAt", consumed_at AS "consumedAt", - revoked_at AS "revokedAt" + revoked_at AS "revokedAt", + requires_connect_approval AS "requiresConnectApproval", + EXISTS ( + SELECT 1 FROM auth_connect_devices + WHERE auth_connect_devices.proof_key_thumbprint = auth_pairing_links.proof_key_thumbprint + ) AS "connectApproved", + requested_client_json AS "requestedClient" FROM auth_pairing_links WHERE revoked_at IS NULL AND consumed_at IS NULL @@ -254,7 +322,9 @@ export const make = Effect.gen(function* () { execute: ({ id, revokedAt }) => sql` UPDATE auth_pairing_links - SET revoked_at = ${revokedAt} + SET revoked_at = ${revokedAt}, + -- Revoking a waiting approval request is a denial; see findPendingConnect. + connect_denied = requires_connect_approval WHERE id = ${id} AND revoked_at IS NULL AND consumed_at IS NULL @@ -278,12 +348,166 @@ export const make = Effect.gen(function* () { created_at AS "createdAt", expires_at AS "expiresAt", consumed_at AS "consumedAt", - revoked_at AS "revokedAt" + revoked_at AS "revokedAt", + requires_connect_approval AS "requiresConnectApproval", + EXISTS ( + SELECT 1 FROM auth_connect_devices + WHERE auth_connect_devices.proof_key_thumbprint = auth_pairing_links.proof_key_thumbprint + ) AS "connectApproved", + requested_client_json AS "requestedClient" FROM auth_pairing_links WHERE credential = ${credential} `, }); + const selectRow = sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + scopes AS "scopes", + subject AS "subject", + label AS "label", + proof_key_thumbprint AS "proofKeyThumbprint", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt", + requires_connect_approval AS "requiresConnectApproval", + EXISTS ( + SELECT 1 FROM auth_connect_devices + WHERE auth_connect_devices.proof_key_thumbprint = auth_pairing_links.proof_key_thumbprint + ) AS "connectApproved", + requested_client_json AS "requestedClient" + FROM auth_pairing_links + `; + + const getActivePairingLinkRowById = SqlSchema.findOneOption({ + Request: GetActiveAuthPairingLinkByIdInput, + Result: AuthPairingLinkRawDbRow, + execute: ({ id, now }) => + sql` + ${selectRow} + WHERE id = ${id} + AND revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + `, + }); + + const findPendingConnectPairingLinkRow = SqlSchema.findOneOption({ + Request: FindPendingConnectPairingLinkInput, + Result: AuthPairingLinkRawDbRow, + execute: ({ proofKeyThumbprint, now }) => + sql` + ${selectRow} + WHERE proof_key_thumbprint = ${proofKeyThumbprint} + AND requires_connect_approval = 1 + -- A denied request stays the device's answer until it expires, so retries get a + -- dead credential back instead of prompting the approver again. + AND (revoked_at IS NULL OR connect_denied = 1) + AND consumed_at IS NULL + AND expires_at > ${now} + ORDER BY created_at DESC, id DESC + LIMIT 1 + `, + }); + + const recordConnectRequestClientRow = SqlSchema.findAll({ + Request: RecordConnectRequestClientInput, + Result: Schema.Struct({ id: Schema.String }), + execute: ({ id, client }) => + sql` + UPDATE auth_pairing_links + SET requested_client_json = ${client} + WHERE id = ${id} + AND requires_connect_approval = 1 + AND requested_client_json IS NULL + RETURNING id AS "id" + `, + }); + + const decodeOptionalRow = + (operation: string) => (rowOption: Option.Option) => + Option.match(rowOption, { + onNone: () => Effect.succeedNone, + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError(operation, cause, { pairingLinkId: row.id }), + ), + Effect.asSome, + ), + }); + + const getActiveById: AuthPairingLinkRepository["Service"]["getActiveById"] = (input) => + getActivePairingLinkRowById(input).pipe( + NodeSqliteClient.readOnly, + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.getActiveById:query", + "AuthPairingLinkRepository.getActiveById:decodeRow", + { pairingLinkId: input.id }, + ), + ), + Effect.flatMap(decodeOptionalRow("AuthPairingLinkRepository.getActiveById:decodeRow")), + ); + + const findPendingConnect: AuthPairingLinkRepository["Service"]["findPendingConnect"] = (input) => + findPendingConnectPairingLinkRow(input).pipe( + NodeSqliteClient.readOnly, + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.findPendingConnect:query", + "AuthPairingLinkRepository.findPendingConnect:decodeRow", + ), + ), + Effect.flatMap(decodeOptionalRow("AuthPairingLinkRepository.findPendingConnect:decodeRow")), + ); + + const recordConnectRequestClient: AuthPairingLinkRepository["Service"]["recordConnectRequestClient"] = + (input) => + recordConnectRequestClientRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.recordConnectRequestClient:query", + "AuthPairingLinkRepository.recordConnectRequestClient:encodeRequest", + { pairingLinkId: input.id }, + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const revokeUnspentByProofKeyRows = SqlSchema.findAll({ + Request: RevokeUnspentByProofKeyInput, + Result: Schema.Struct({ id: Schema.String }), + execute: ({ proofKeyThumbprint, keepProofKeyThumbprint, revokedAt }) => + sql` + -- Only T3 Connect mints bind a proof key, so these are that key's connect credentials. + UPDATE auth_pairing_links + SET revoked_at = ${revokedAt} + WHERE proof_key_thumbprint IS NOT NULL + AND revoked_at IS NULL + AND consumed_at IS NULL + AND (${proofKeyThumbprint} IS NULL OR proof_key_thumbprint = ${proofKeyThumbprint}) + AND proof_key_thumbprint IS NOT ${keepProofKeyThumbprint} + RETURNING id AS "id" + `, + }); + + const revokeUnspentByProofKey: AuthPairingLinkRepository["Service"]["revokeUnspentByProofKey"] = ( + input, + ) => + revokeUnspentByProofKeyRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.revokeUnspentByProofKey:query", + "AuthPairingLinkRepository.revokeUnspentByProofKey:decodeRows", + ), + ), + Effect.map((rows) => rows.map((row) => row.id)), + ); + const create: AuthPairingLinkRepository["Service"]["create"] = (input) => createPairingLinkRow(input).pipe( Effect.mapError( @@ -390,6 +614,10 @@ export const make = Effect.gen(function* () { listActive, revoke, getByCredential, + getActiveById, + findPendingConnect, + recordConnectRequestClient, + revokeUnspentByProofKey, } satisfies AuthPairingLinkRepository["Service"]; }); diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 7b82b012e674..04f10dd34b0b 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -52,6 +52,7 @@ export const CreateAuthSessionInput = Schema.Struct({ client: AuthSessionClientMetadataRecord, issuedAt: Schema.DateTimeUtcFromString, expiresAt: Schema.DateTimeUtcFromString, + proofKeyThumbprint: Schema.optionalKey(Schema.String), }); export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; @@ -86,6 +87,12 @@ export const RevokeOtherAuthSessionsInput = Schema.Struct({ }); export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; +export const RevokeAuthSessionsByProofKeyInput = Schema.Struct({ + proofKeyThumbprint: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthSessionsByProofKeyInput = typeof RevokeAuthSessionsByProofKeyInput.Type; + export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ sessionId: AuthSessionId, lastConnectedAt: Schema.DateTimeUtcFromString, @@ -119,6 +126,12 @@ export class AuthSessionRepository extends Context.Service< readonly setLastConnectedAt: ( input: SetAuthSessionLastConnectedAtInput, ) => Effect.Effect; + readonly getProofKeyThumbprint: ( + input: GetAuthSessionByIdInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly revokeByProofKey: ( + input: RevokeAuthSessionsByProofKeyInput, + ) => Effect.Effect, AuthSessionRepositoryError>; } >()("t3/persistence/AuthSessions/AuthSessionRepository") {} @@ -216,7 +229,8 @@ export const make = Effect.gen(function* () { client_browser, issued_at, expires_at, - revoked_at + revoked_at, + proof_key_thumbprint ) VALUES ( ${input.sessionId}, @@ -231,7 +245,8 @@ export const make = Effect.gen(function* () { ${input.client.browser}, ${input.issuedAt}, ${input.expiresAt}, - NULL + NULL, + ${input.proofKeyThumbprint ?? null} ) ${ignoreExisting ? sql`ON CONFLICT(session_id) DO NOTHING` : sql``} `, @@ -334,6 +349,31 @@ export const make = Effect.gen(function* () { `, }); + const proofKeyThumbprintRows = SqlSchema.findAll({ + Request: GetAuthSessionByIdInput, + Result: Schema.Struct({ proofKeyThumbprint: Schema.String }), + execute: ({ sessionId }) => + sql` + SELECT proof_key_thumbprint AS "proofKeyThumbprint" + FROM auth_sessions + WHERE session_id = ${sessionId} + AND proof_key_thumbprint IS NOT NULL + `, + }); + + const revokeByProofKeyRows = SqlSchema.findAll({ + Request: RevokeAuthSessionsByProofKeyInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ proofKeyThumbprint, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE proof_key_thumbprint = ${proofKeyThumbprint} + AND revoked_at IS NULL + RETURNING session_id AS "sessionId" + `, + }); + const revokeOtherSessionRows = SqlSchema.findAll({ Request: RevokeOtherAuthSessionsInput, Result: Schema.Struct({ sessionId: AuthSessionId }), @@ -480,6 +520,32 @@ export const make = Effect.gen(function* () { ), ); + const getProofKeyThumbprint: AuthSessionRepository["Service"]["getProofKeyThumbprint"] = ( + input, + ) => + proofKeyThumbprintRows(input).pipe( + NodeSqliteClient.readOnly, + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.getProofKeyThumbprint:query", + "AuthSessionRepository.getProofKeyThumbprint:decodeRows", + { sessionId: input.sessionId }, + ), + ), + Effect.map((rows) => Option.fromNullishOr(rows[0]?.proofKeyThumbprint)), + ); + + const revokeByProofKey: AuthSessionRepository["Service"]["revokeByProofKey"] = (input) => + revokeByProofKeyRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.revokeByProofKey:query", + "AuthSessionRepository.revokeByProofKey:decodeRows", + ), + ), + Effect.map((rows) => rows.map((row) => row.sessionId)), + ); + return { create, createReplacingActive, @@ -489,6 +555,8 @@ export const make = Effect.gen(function* () { revoke, revokeAllExcept, setLastConnectedAt, + getProofKeyThumbprint, + revokeByProofKey, } satisfies AuthSessionRepository["Service"]; }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 6399ee7b8595..f1b8f6c9d806 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -96,6 +96,9 @@ import Migration0085 from "./Migrations/085_TurnItemCounts.ts"; import Migration0086 from "./Migrations/086_QueuedTurnInputIndex.ts"; import Migration0087 from "./Migrations/087_ProjectionProjectsCustomTitle.ts"; import Migration0088 from "./Migrations/088_TurnItemTypeStatusIndex.ts"; +import Migration0089 from "./Migrations/089_ShellThreadReadIndexes.ts"; +import Migration0090 from "./Migrations/090_AuthConnectDevices.ts"; +import Migration0091 from "./Migrations/091_AuthConnectDeniedRequests.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -187,6 +190,9 @@ const migrationEntries = [ [86, "QueuedTurnInputIndex", Migration0086], [87, "ProjectionProjectsCustomTitle", Migration0087], [88, "TurnItemTypeStatusIndex", Migration0088], + [89, "ShellThreadReadIndexes", Migration0089], + [90, "AuthConnectDevices", Migration0090], + [91, "AuthConnectDeniedRequests", Migration0091], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts b/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts new file mode 100644 index 000000000000..25133aa84be6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Keep these expressions identical to ProjectionStore's shell worktree and family filters. + // json_valid keeps a malformed payload writable, as the other payload indexes do. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_worktree_path_idx + ON orchestration_v2_projection_threads( + CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.worktreePath') END + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_root_thread_idx + ON orchestration_v2_projection_threads( + CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.lineage.rootThreadId') END + ) + `; + // Shell item counts read only a run's status; the primary key would load the whole row. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_runs_run_status_idx + ON orchestration_v2_projection_runs(run_id, status) + `; +}); diff --git a/apps/server/src/persistence/Migrations/090_AuthConnectDevices.ts b/apps/server/src/persistence/Migrations/090_AuthConnectDevices.ts new file mode 100644 index 000000000000..2d7e973cedbc --- /dev/null +++ b/apps/server/src/persistence/Migrations/090_AuthConnectDevices.ts @@ -0,0 +1,40 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) + `; + if (!pairingLinkColumns.some((column) => column.name === "requires_connect_approval")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN requires_connect_approval INTEGER NOT NULL DEFAULT 0 + `; + } + if (!pairingLinkColumns.some((column) => column.name === "requested_client_json")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN requested_client_json TEXT + `; + } + + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + if (!sessionColumns.some((column) => column.name === "proof_key_thumbprint")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN proof_key_thumbprint TEXT + `; + } + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_connect_devices ( + proof_key_thumbprint TEXT PRIMARY KEY, + client_json TEXT, + approved_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/091_AuthConnectDeniedRequests.ts b/apps/server/src/persistence/Migrations/091_AuthConnectDeniedRequests.ts new file mode 100644 index 000000000000..a27b65294898 --- /dev/null +++ b/apps/server/src/persistence/Migrations/091_AuthConnectDeniedRequests.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) + `; + if (!pairingLinkColumns.some((column) => column.name === "connect_denied")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN connect_denied INTEGER NOT NULL DEFAULT 0 + `; + } +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index a2bba7d790ea..89c06944d2bf 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -68,6 +68,9 @@ describe("V2 preview upgrade", () => { [86, "QueuedTurnInputIndex"], [87, "ProjectionProjectsCustomTitle"], [88, "TurnItemTypeStatusIndex"], + [89, "ShellThreadReadIndexes"], + [90, "AuthConnectDevices"], + [91, "AuthConnectDeniedRequests"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -179,6 +182,9 @@ describe("V2 preview upgrade", () => { [86, "QueuedTurnInputIndex"], [87, "ProjectionProjectsCustomTitle"], [88, "TurnItemTypeStatusIndex"], + [89, "ShellThreadReadIndexes"], + [90, "AuthConnectDevices"], + [91, "AuthConnectDeniedRequests"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 1c1699212320..28598cfbdfe1 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -79,6 +79,18 @@ it.layer(layer)("PreviewManager", (it) => { }), ); + it.effect("opens a server tab with the requested zoom and appearance", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const rendering = { zoomFactor: 0.8, colorScheme: "dark" } as const; + + const opened = yield* manager.open({ threadId, runtime: "server", ...rendering }); + + expect(opened).toMatchObject(rendering); + }), + ); + it.effect("reissues presentation requests without replaying them on navigation", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index ff8442306f2f..0899866f80b6 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -193,6 +193,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), ...(runtime === undefined ? {} : { runtime }), + ...(input.colorScheme === undefined ? {} : { colorScheme: input.colorScheme }), + ...(input.zoomFactor === undefined ? {} : { zoomFactor: input.zoomFactor }), ...(runtime === "server" && input.automationOwner !== undefined ? { automationOwner: input.automationOwner } : {}), diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index 50ba7b91c56f..203ec112cccb 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -25,31 +25,31 @@ import { openZipArchive } from "../zipArchive.ts"; // https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json // Never pin below 156.0.8078.70: the shell has no CRLSet and older SPKI blocklists trust // compromised eMudhra intermediates (crrev.com/c/8627687). -const VERSION = "157.0.8103.38"; +const VERSION = "157.0.8103.78"; const ARCHIVES = { linux64: { - bytes: 128_168_542, - sha256: "deafffa74660b34853a4119c361fd427ac8c9fcb62db51ddb8a797cd83a4ba2d", + bytes: 127_965_683, + sha256: "f90329720fb61e5a76da9b6b509b6ad12a484c9c5df0359403e935177c6d153a", }, "linux-arm64": { - bytes: 128_171_139, - sha256: "b9b910a57b2a15eb3e55000a2339f93a973934a413ebfc6e585a50ec6c42fd6c", + bytes: 128_047_138, + sha256: "912fbf877e53de34d7b3cca5104f3b95607d41bd35412ccc6cd2a599ff0c0dbf", }, "mac-arm64": { - bytes: 104_773_711, - sha256: "e6a6b13551aa12f951d0891d79282606b13742837c8fa0505e1272bae116ce82", + bytes: 104_822_985, + sha256: "17bf66d53c1b1104dcb641c5b331cd4ac5d200c778f5feed0934a0b92deaf8d6", }, "mac-x64": { - bytes: 110_442_262, - sha256: "79821745d0a72d2ba752d981284ed0a9e6fa320dad133596941a17dba6186de3", + bytes: 110_132_547, + sha256: "f1a6a9561eaf1f409849538de663695d36d72fbf1ebca4d42e47c7b5f2a157f3", }, win32: { - bytes: 120_390_416, - sha256: "35e0d45c0136448f7b587f6fb7195a0686127a4dfdc23ef3b0e04b5e9ffe16cd", + bytes: 120_403_126, + sha256: "d9829c59dcc79d9e6e920f8ab841f2a9a055dcf54229d756ac0b42dfec303910", }, win64: { - bytes: 126_513_060, - sha256: "1ff4540855478ba0848e03f1ef8d455f77d058b38285df91bc34273788a73446", + bytes: 126_047_001, + sha256: "2138c1583d25d6f3abec5269943b2f430207d87a539895aa9e90cc4697ec0e1f", }, } as const; diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts index 1847e5f23207..0952dbaf944c 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -126,13 +126,18 @@ export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries") return listed.length > 0 ? listed : [loaderError]; }); -export const sandboxBlocked = Effect.gen(function* () { +export const usernsRestricted = Effect.gen(function* () { if ((yield* HostProcess.Platform) !== "linux") return false; const fs = yield* FileSystem.FileSystem; - const restricted = yield* fs.readFileString(USERNS_RESTRICTION).pipe( + return yield* fs.readFileString(USERNS_RESTRICTION).pipe( Effect.map((value) => value.trim() === "1"), Effect.orElseSucceed(() => false), ); - if (!restricted) return false; +}).pipe(Effect.withSpan("PreviewBrowserHost.usernsRestricted")); + +/** A profile on disk can still be unloaded; only a failed launch shows that without root. */ +export const sandboxBlocked = Effect.gen(function* () { + if (!(yield* usernsRestricted)) return false; + const fs = yield* FileSystem.FileSystem; return !(yield* fs.exists(APPARMOR_PROFILE_PATH).pipe(Effect.orElseSucceed(() => false))); }).pipe(Effect.withSpan("PreviewBrowserHost.sandboxBlocked")); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index d2897987dc1f..d59738624f41 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -213,11 +213,11 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = " viewport = size; }), goto: vi.fn(async (next: string, _options?: { timeout?: number; waitUntil?: string }) => { - url = next; - events.emit("load"); const pending = initialNavigation; initialNavigation = null; await pending?.(); + url = next; + events.emit("load"); }), goBack: vi.fn(async () => {}), goForward: vi.fn(async () => {}), @@ -360,12 +360,10 @@ function makeEncoderPage() { document: { createElement: (tag: string) => (tag === "video" ? video : canvas) }, Path2D: function Path2D() {}, MediaRecorder: FakeMediaRecorder, - createImageBitmap: async (blob: Blob) => ({ - width: 1280, - height: 800, - pixels: await blob.text(), - close() {}, - }), + createImageBitmap: async (blob: Blob) => { + await decodeGate?.promise; + return { width: 1280, height: 800, pixels: await blob.text(), close() {} }; + }, atob, btoa, Blob, @@ -386,6 +384,7 @@ function makeEncoderPage() { const contexts: ReturnType[] = []; let encoderPageOverride: Page | undefined; const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = []; +let decodeGate: PromiseWithResolvers | null = null; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; let heldStill: (() => Promise) | null = null; @@ -571,6 +570,7 @@ beforeEach(() => { contexts.length = 0; encoderPageOverride = undefined; encoderStreams.length = 0; + decodeGate = null; contextRequests.length = 0; contextGate = null; heldStill = null; @@ -802,6 +802,74 @@ it.effect("a desktop status answers with the last viewport while the renderer ca ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), Effect.provide(layer)), ); +it.effect.each([ + { name: "an agent navigate", message: null }, + { name: "a viewer navigate", message: { type: "navigate", url: "http://localhost:5173/next" } }, + { name: "a viewer Back", message: { type: "history", delta: -1 } }, + { name: "a viewer Forward", message: { type: "history", delta: 1 } }, + { name: "a viewer Reload", message: { type: "reload" } }, + { name: "a viewer hard reload", message: { type: "reload", ignoreCache: true } }, +])("$name stops a page script that never yields before it navigates", ({ message }) => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const viewer = message ? yield* browser.attachViewer(viewerInput(tabId, true)) : null; + if (viewer) yield* viewer.input({ type: "takeControl" }); + const { page, sessions } = contexts[0]!; + // As in Chrome: the hung renderer answers nothing, and a terminate reaches it only while + // no navigation is pending. + let stopped = false; + const probed = Promise.withResolvers(); + const navigatedAfterStop: Array = []; + for (const cdp of sessions) { + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation((method, input) => { + if ( + method === "Runtime.evaluate" && + (input as { expression: string }).expression === "0" + ) { + probed.resolve(); + return new Promise(() => {}); + } + if (method === "Runtime.terminateExecution") stopped = true; + if (method === "Page.reload") navigatedAfterStop.push(stopped); + return send(method, input); + }); + } + const goto = page.goto.getMockImplementation()!; + page.goto.mockImplementation(async (next, options) => { + navigatedAfterStop.push(stopped); + return goto(next, options); + }); + for (const method of ["goBack", "goForward", "reload"] as const) { + page[method].mockImplementation(async () => { + navigatedAfterStop.push(stopped); + }); + } + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const result = yield* ( + viewer + ? viewer.input(message) + : broker.invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://localhost:5173/next", timeoutMs: 5_000 }, + }) + ).pipe(Effect.forkScoped); + const probing = yield* Effect.raceFirst( + Effect.promise(() => probed.promise).pipe(Effect.as(true)), + Fiber.await(result).pipe(Effect.as(false)), + ); + if (probing) yield* Effect.promise(() => vi.advanceTimersByTimeAsync(1_000)); + yield* Fiber.join(result); + + expect(navigatedAfterStop).toEqual([true]); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), Effect.provide(layer)), +); + it.live("navigate fails when the page never commits within its timeout", () => Effect.scoped( Effect.gen(function* () { @@ -998,15 +1066,117 @@ it.effect("a desktop page that loaded before the server attached reports its loa ).pipe(Effect.provide(layer)), ); +it.effect.each(["none", "during", "after-fragment", "same-url"])( + "Stop settles an initial navigation with its request event $0", + (requestTiming) => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker } = yield* ready; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + const started = Promise.withResolvers(); + const committed = Promise.withResolvers(); + initialNavigation = () => { + started.resolve(); + return committed.promise; + }; + yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve())); + const { tabId } = yield* manager.open({ + threadId: scope.thread.threadId, + runtime: "server", + url: + requestTiming === "after-fragment" + ? "https://example.com/pending#section" + : "https://example.com/pending", + }); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* Effect.promise(() => started.promise); + const page = contexts.at(-1)!.page; + const request = { + url: () => "https://example.com/pending", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }; + const cdp = contexts.at(-1)!.sessions.at(-1)!; + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation(async (operation, input) => { + if (operation === "Page.stopLoading") { + committed.reject(new Error("net::ERR_ABORTED")); + if (requestTiming === "during") page.emit("request", request); + } + return send(operation, input); + }); + yield* viewer.input({ type: "stopLoading" }); + const stopped = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(stopped.loading).toBe(false); + let settled = yield* PubSub.take(events); + while ( + settled.type !== "navigated" || + settled.tabId !== tabId || + settled.snapshot.navStatus._tag !== "Idle" + ) { + settled = yield* PubSub.take(events); + } + const corrected = requestTiming === "same-url"; + using timeout = vi.spyOn(globalThis, "setTimeout"); + if (corrected) { + page.goto.mockImplementationOnce(async () => { + page.emit("request", request); + }); + yield* viewer.input({ type: "navigate", url: request.url() }); + } else if (requestTiming !== "none") { + if (requestTiming === "after-fragment") page.emit("request", request); + page.emit("requestfailed", request); + } + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.loading).toBe(corrected); + expect(timeout).not.toHaveBeenCalledWith(expect.any(Function), 5_000); + if (!corrected) { + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions.find((session) => session.tabId === tabId)?.navStatus).toEqual({ + _tag: "Idle", + }); + } + }), + ).pipe(Effect.provide(layer)), +); + it.effect.each([ - { method: "goto" as const, message: { type: "navigate", url: "http://10.255.255.1/" } }, - { method: "goBack" as const, message: { type: "history", delta: -1 } }, - { method: "goForward" as const, message: { type: "history", delta: 1 } }, - { method: "reload" as const, message: { type: "reload" } }, -])("a pending viewer $method allows a corrected URL and text input", ({ method, message }) => + { + method: "goto" as const, + message: { type: "navigate", url: "http://10.255.255.1/" }, + abortEvent: "before", + }, + { method: "goBack" as const, message: { type: "history", delta: -1 }, abortEvent: "before" }, + { method: "goForward" as const, message: { type: "history", delta: 1 }, abortEvent: "before" }, + { method: "reload" as const, message: { type: "reload" }, abortEvent: "after" }, + { method: "reload" as const, message: { type: "reload" }, abortEvent: "none" }, + ...["before", "after", "none"].map((abortEvent) => ({ + method: "goto" as const, + message: { type: "navigate", url: "http://10.255.255.1/" }, + abortEvent, + blank: true, + })), +])("viewer $method Stop settles ($abortEvent, blank=$blank)", (testCase) => Effect.scoped( Effect.gen(function* () { - const { browser, tabId } = yield* ready; + const { method, message, abortEvent } = testCase; + const blank = "blank" in testCase; + const { browser, broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); yield* viewer.input({ type: "takeControl" }); const started = Promise.withResolvers(); @@ -1020,26 +1190,77 @@ it.effect.each([ yield* Effect.promise(() => started.promise); const cdp = contexts[0]!.sessions.at(-1)!; const send = cdp.send.getMockImplementation()!; + const request = { + url: () => "https://example.com/pending", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }; const typed = Promise.withResolvers(); + let cancelled = false; cdp.send.mockImplementation(async (operation, input) => { if (operation === "Input.insertText") typed.resolve(); + if (operation === "Page.stopLoading") { + cancelled = true; + committed.reject(new Error("net::ERR_ABORTED")); + if (abortEvent === "before") page.emit("requestfailed", request); + } return send(operation, input); }); const correction = yield* Effect.gen(function* () { - yield* viewer.input({ type: "navigate", url: "http://localhost:5173/fixed" }); + if (!blank) yield* viewer.input({ type: "navigate", url: "http://localhost:5173/fixed" }); yield* viewer.input({ type: "text", text: "hello" }); }).pipe(Effect.forkScoped); yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve())); // The text reaches the page while the first navigation is still uncommitted. yield* Effect.promise(() => typed.promise); expect(page.goto).toHaveBeenLastCalledWith( - "http://localhost:5173/fixed", + blank ? "http://10.255.255.1/" : "http://localhost:5173/fixed", expect.objectContaining({ waitUntil: "commit" }), ); expect(contexts[0]!.sessions.at(-1)!.send).toHaveBeenCalledWith("Input.insertText", { text: "hello", }); - committed.resolve(); + page.emit("request", request); + let loadingEvent = yield* PubSub.take(events); + while ( + loadingEvent.type !== "navigated" || + loadingEvent.snapshot.navStatus._tag !== "Loading" + ) { + loadingEvent = yield* PubSub.take(events); + } + const reader = yield* browser.attachViewer(viewerInput(tabId, false)); + yield* reader.input({ type: "stopLoading" }); + expect(cancelled).toBe(false); + if (abortEvent === "none" && !blank) committed.resolve(); + yield* viewer.input({ type: "stopLoading" }); + expect(cancelled).toBe(true); + using timeout = vi.spyOn(globalThis, "setTimeout"); + if (abortEvent === "after") page.emit("requestfailed", request); + expect(timeout).not.toHaveBeenCalledWith(expect.any(Function), 5_000); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.loading).toBe(false); + const expectedStatus = blank ? "Idle" : "Success"; + let settled = yield* PubSub.take(events); + while (settled.type !== "navigated" || settled.snapshot.navStatus._tag !== expectedStatus) { + settled = yield* PubSub.take(events); + } + if (blank) { + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions.find((session) => session.tabId === tabId)?.navStatus).toEqual({ + _tag: "Idle", + }); + } + if (settled.snapshot.navStatus._tag === "Success") + expect(settled.snapshot.navStatus.url).toBe("http://localhost:5173/fixed"); + expect(page[method]).toHaveBeenCalledTimes(method === "goto" && !blank ? 2 : 1); + yield* viewer.input({ type: "releaseControl" }); yield* Fiber.join(navigation); yield* Fiber.join(correction); }), @@ -1077,6 +1298,32 @@ it.effect.each([ ).pipe(Effect.provide(layer)), ); +// Chrome stops sending screencast frames while too many are unacked. +it.effect("recordings ack screencast frames without waiting for the encoder to decode them", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + yield* broker.invoke({ scope, tabId, operation: "recordingStart", input: {} }); + const session = contexts[0]!.sessions.find((candidate) => + candidate.send.mock.calls.some(([method]) => method === "Page.startScreencast"), + )!; + const gate = Promise.withResolvers(); + decodeGate = gate; + yield* Effect.addFinalizer(() => Effect.sync(() => gate.resolve())); + for (const sessionId of [1, 2, 3]) { + session.emit("Page.screencastFrame", { + data: Buffer.from(`frame ${sessionId}`).toString("base64"), + sessionId, + metadata: { deviceWidth: 1280 }, + }); + } + expect( + session.send.mock.calls.filter(([method]) => method === "Page.screencastFrameAck"), + ).toHaveLength(3); + }), + ).pipe(Effect.provide(layer)), +); + it.effect.each([{ truncated: false }, { truncated: true }])( "saves a large recording as a binary download and removes its staged file (truncated: $truncated)", ({ truncated }) => @@ -1627,6 +1874,34 @@ it.effect("closing a tab with an unsaved-changes guard still closes it", () => ).pipe(Effect.provide(layer)), ); +it.effect("a tab whose renderer crashes stays open instead of vanishing", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const url = `https://example.com/app?${"a".repeat(3000)}`; + contexts[0]!.page.commit(url); + yield* Effect.promise(() => contexts[0]!.page.emitAsync("crash")); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.tabId).toBe(tabId); + expect(status.loadError).toEqual({ + url: url.slice(0, 2048), + code: -2, + description: "Page crashed", + }); + // The crashed page cannot load anything again, so Reload goes to the page that replaced it. + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + yield* viewer.input({ type: "reload" }); + expect(contexts[1]!.page.goto).toHaveBeenCalledWith(url, expect.anything()); + }), + ).pipe(Effect.provide(layer)), +); + it.effect("a tab opened to a file the browser cannot show reports the file to download", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index fdffcc49414e..c87e829130e2 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -377,10 +377,16 @@ interface ServerTab { desktopResize: PromiseWithResolvers; loading: boolean; loadError: PreviewAutomationLoadError | undefined; + /** The renderer crash this page died of, or the one the page it replaced died of. */ + crash: PreviewAutomationLoadError | undefined; + /** What Reload loads after a crash; the reported URL is shortened and drops data payloads. */ + crashedUrl: string | undefined; closing: boolean; recording: Recording | null; initialNavigation: Promise | null; + pendingInitialRequestUrl: string | null; navigationGeneration: number; + stoppedGeneration: number | null; abortedNavigation: { readonly url: string; @@ -584,6 +590,51 @@ const make = Effect.gen(function* () { }, }); + // Playwright cannot reload a crashed page, so a fresh page takes over its tab. + const reopenCrashedTab = async (tab: ServerTab) => { + if (tab.desktop) return dropTab(tab, true); + const key = tabKey(tab.threadId, tab.tabId); + if (tabs.get(key) !== tab) return; + const crash = { + url: ServerBrowserPage.withoutDataPayload(tab.page.url()).slice(0, 2048), + code: -2, + description: "Page crashed", + }; + tab.crash = crash; + const reopening = (async () => { + // The session must name the crash before a new page reads it, or it would load the URL again. + await runPromise( + manager + .reportStatus({ + threadId: tab.threadId, + tabId: tab.tabId, + serverControlled: true, + navStatus: { _tag: "LoadFailed", title: "", ...crash }, + canGoBack: false, + canGoForward: false, + }) + .pipe(Effect.ignore), + ); + dropTab(tab, false, true); + // A context that is still closing would take the new page down with it. + if (tab.isolatedContext) await tab.page.context().close().catch(constVoid); + const { sessions } = await runPromise(manager.list({ threadId: tab.threadId })); + const snapshot = sessions.find( + (session) => session.tabId === tab.tabId && session.runtime === "server", + ); + if (!snapshot) { + throw new ServerBrowserTabNotFoundError({ threadId: tab.threadId, tabId: tab.tabId }); + } + return createTab(snapshot, tab.page.url()); + })().finally(() => { + pendingTabs.delete(key); + closedPendingTabs.delete(key); + }); + // Reserved before the dead page leaves, so a lookup in between waits for its replacement. + pendingTabs.set(key, { agentId: tab.control.agentId, promise: reopening }); + await reopening.catch(constVoid); + }; + const dialogStatus = (tab: ServerTab) => tab.dialog ? { @@ -636,6 +687,8 @@ const make = Effect.gen(function* () { .send("Page.getNavigationHistory") .catch(() => null) .then((history) => { + // A status the dead page still had in flight must not replace the crash. + if (tab.crash) return; const index = history?.currentIndex ?? 0; const count = history?.entries.length ?? 0; runFork( @@ -830,7 +883,7 @@ const make = Effect.gen(function* () { clearTimeout(timeout); }; - const dropTab = (tab: ServerTab, closeSession: boolean) => { + const dropTab = (tab: ServerTab, closeSession: boolean, reopening = false) => { const key = tabKey(tab.threadId, tab.tabId); if (tabs.get(key) !== tab) return; tabs.delete(key); @@ -838,7 +891,8 @@ const make = Effect.gen(function* () { tab.viewerAttached?.resolve(); clearAbortedNavigation(tab); // A desktop page outlives the connection unless its session closed with it. - const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; + const end = + (tab.desktop || reopening) && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; for (const viewer of tab.viewers) viewer.push({ _tag: end }); void tab.control.close().catch(constVoid); if (tab.desktop) void tab.desktop.close().catch(constVoid); @@ -853,7 +907,8 @@ const make = Effect.gen(function* () { const forceClose = setTimeout(() => void page.close().catch(constVoid), PAGE_CLOSE_GRACE_MS); void closed.then(() => clearTimeout(forceClose)); void page - .close({ runBeforeUnload: tab.dialog === null }) + // A crashed page never answers `beforeunload`, so that close would not finish. + .close({ runBeforeUnload: tab.dialog === null && tab.crash === undefined }) .catch(() => page.close()) .catch(constVoid); if (tab.isolatedContext) void closed.then(() => page.context().close()).catch(constVoid); @@ -905,10 +960,13 @@ const make = Effect.gen(function* () { } }; - const createTab = async (snapshot: PreviewSessionSnapshot): Promise => { + const createTab = async ( + snapshot: PreviewSessionSnapshot, + crashedUrl?: string, + ): Promise => { const opened: Array<() => Promise> = []; try { - return await setUpTab(snapshot, opened); + return await setUpTab(snapshot, opened, crashedUrl); } catch (cause) { // An unpublished tab is in no list, so nothing else would ever close it. for (const close of opened.reverse()) await close().catch(constVoid); @@ -919,6 +977,7 @@ const make = Effect.gen(function* () { const setUpTab = async ( snapshot: PreviewSessionSnapshot, opened: Array<() => Promise>, + crashedUrl: string | undefined, ): Promise => { const adopted = adoptedPages.get(tabKey(snapshot.threadId, snapshot.tabId)); adoptedPages.delete(tabKey(snapshot.threadId, snapshot.tabId)); @@ -1007,11 +1066,23 @@ const make = Effect.gen(function* () { desktopResize: Promise.withResolvers(), loading: false, loadError: undefined, + // The page a crash left behind is blank; the session still names what crashed. + crash: + !desktop && !adopted && snapshot.navStatus._tag === "LoadFailed" + ? { + url: snapshot.navStatus.url, + code: snapshot.navStatus.code, + description: snapshot.navStatus.description, + } + : undefined, + crashedUrl, closing: false, recording: null, recordingStart: null, initialNavigation: null, + pendingInitialRequestUrl: null, navigationGeneration: 0, + stoppedGeneration: null, abortedNavigation: null, captureLock: Promise.resolve(), capturing: 0, @@ -1047,10 +1118,22 @@ const make = Effect.gen(function* () { const navigationGenerations = new WeakMap(); page.on("request", (request) => { if (!isMainNavigation(request)) return; + // Playwright can deliver the first request after Stop has already settled its navigation. + const stoppedInitialRequest = + request.url() === tab.pendingInitialRequestUrl && + tab.stoppedGeneration === tab.navigationGeneration; + tab.pendingInitialRequestUrl = null; navigationGenerations.set(request, ++tab.navigationGeneration); clearAbortedNavigation(tab); tab.loading = true; tab.loadError = undefined; + tab.crash = undefined; + tab.crashedUrl = undefined; + if (stoppedInitialRequest) { + tab.stoppedGeneration = tab.navigationGeneration; + settleStoppedNavigation(tab, tab.navigationGeneration); + return; + } report(tab, { _tag: "Loading", url: ServerBrowserPage.withoutDataPayload(request.url()).slice(0, 2048), @@ -1058,6 +1141,7 @@ const make = Effect.gen(function* () { }); }); page.on("load", () => { + tab.pendingInitialRequestUrl = null; tab.loading = false; void reportLoaded(tab); }); @@ -1090,6 +1174,10 @@ const make = Effect.gen(function* () { if (generation !== tab.navigationGeneration) return; // An aborted navigation is usually a download; its `download` event settles the status. if (errorText.includes("ERR_ABORTED")) { + if (tab.stoppedGeneration === generation) { + settleStoppedNavigation(tab, generation); + return; + } const url = request.url(); const navigationGeneration = tab.navigationGeneration; clearAbortedNavigation(tab); @@ -1139,14 +1227,13 @@ const make = Effect.gen(function* () { updateFileChooserInterception(tab); // Popups become tabs and keep `window.opener`, so sign-in popups can report back. page.on("popup", (popup) => void adoptPopup(tab, popup)); - // Playwright cannot reload a crashed page, so it must leave the tab list. const key = tabKey(tab.threadId, tab.tabId); if (closedPendingTabs.delete(key)) { await control.close().catch(constVoid); throw new ServerBrowserTabNotFoundError({ threadId: tab.threadId, tabId: tab.tabId }); } page.on("close", () => dropTab(tab, true)); - page.on("crash", () => dropTab(tab, true)); + page.on("crash", () => reopenCrashedTab(tab)); tabs.set(key, tab); reportLiveTabs(); // A desktop page navigates on its own and can finish loading before these listeners attach. @@ -1156,6 +1243,10 @@ const make = Effect.gen(function* () { snapshot.navStatus._tag === "Loading" && ((!adopted && !desktop) || isPdfAssetUrl(snapshot.navStatus.url)) ) { + tab.loading = true; + const initialUrl = URL.parse(snapshot.navStatus.url); + if (initialUrl) initialUrl.hash = ""; + tab.pendingInitialRequestUrl = initialUrl?.href ?? null; const options = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; tab.initialNavigation = ( (desktop || adopted) && page.url() === snapshot.navStatus.url @@ -1193,6 +1284,14 @@ const make = Effect.gen(function* () { tab.abortedNavigation = null; }; + const settleStoppedNavigation = (tab: ServerTab, generation: number) => { + if (tab.navigationGeneration !== generation || !tab.loading) return; + clearAbortedNavigation(tab); + tab.loading = false; + if (tab.page.url() === "about:blank") report(tab, { _tag: "Idle" }); + else void reportLoaded(tab); + }; + const settleAbortedNavigation = ( tab: ServerTab, generation: number, @@ -1797,6 +1896,7 @@ const make = Effect.gen(function* () { }; const loadErrorOf = (tab: ServerTab): PreviewAutomationLoadError | undefined => { + if (tab.crash) return tab.crash; if (!tab.page.url().startsWith("chrome-error://")) return undefined; // A desktop failure from before CDP attached is superseded by any navigation we observe. if (tab.loadError || !tab.desktop || tab.navigationGeneration > 0) return tab.loadError; @@ -1917,14 +2017,21 @@ const make = Effect.gen(function* () { return `${target.protocol ?? "http"}://localhost:${target.port}${path.startsWith("/") || path === "" ? path : `/${path}`}`; }; + /** A pending dialog also leaves the probe unanswered, but its script is waiting, not stuck. */ + const leaveBusyPage = (tab: ServerTab, cdp: CDPSession, budgetMs?: number) => + tab.dialog ? Promise.resolve() : ServerBrowserPage.stopUnresponsiveScript(cdp, budgetMs); + const navigate = async ( tab: ServerTab, url: string, readiness: "load" | "domContentLoaded" | "none", timeout: number, ) => { + tab.pendingInitialRequestUrl = null; const startedAt = performance.now(); - const navigation = tab.page.goto(url, { timeout, waitUntil: "commit" }); + const remaining = () => Math.max(1, timeout - (performance.now() - startedAt)); + await leaveBusyPage(tab, tab.cdp, timeout / 2); + const navigation = tab.page.goto(url, { timeout: remaining(), waitUntil: "commit" }); if (readiness === "none") { tab.control.track(navigation); return; @@ -1933,7 +2040,7 @@ const make = Effect.gen(function* () { const ready = () => tab.page .waitForLoadState(readiness === "domContentLoaded" ? "domcontentloaded" : "load", { - timeout: Math.max(1, timeout - (performance.now() - startedAt)), + timeout: remaining(), }) .catch((cause: unknown) => { if (!(cause instanceof Error && cause.name === "TimeoutError")) throw cause; @@ -1986,24 +2093,37 @@ const make = Effect.gen(function* () { const opened = await tab.page.context().newCDPSession(tab.page); session = opened; const framesInFlight = new Set>(); + // Chrome withholds frames while acks are outstanding, so ack on arrival and let a + // slow encoder skip to the newest frame instead of throttling the capture. + let pendingFrame: readonly [string, number] | null = null; + const deliverPending = async () => { + while (pendingFrame) { + const next = pendingFrame; + pendingFrame = null; + const accepted = await encoder + .evaluate( + ([data, width]) => + (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), + next, + ) + .catch(() => true); + if (!accepted) { + pendingFrame = null; + await opened.send("Page.stopScreencast").catch(constVoid); + } + } + }; opened.on("Page.screencastFrame", (frame) => { + void opened + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(constVoid); const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; - const delivered: Promise = encoder - .evaluate( - ([data, width]) => - (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), - [frame.data, cssWidth] as const, - ) - .then(async (accepted) => { - if (!accepted) await opened.send("Page.stopScreencast"); - }) - .catch(constVoid) - .finally(() => { - framesInFlight.delete(delivered); - void opened - .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(constVoid); - }); + const delivering = framesInFlight.size > 0; + pendingFrame = [frame.data, cssWidth]; + if (delivering) return; + const delivered: Promise = deliverPending().finally(() => + framesInFlight.delete(delivered), + ); framesInFlight.add(delivered); }); await opened.send("Page.startScreencast", RECORDING_SCREENCAST); @@ -2581,7 +2701,11 @@ const make = Effect.gen(function* () { return loadError ? { ...snapshot, url: loadError.url, loadError } : snapshot; } case "click": { - const clickInput = input as PreviewAutomationClickInput; + // Time spent queued behind other work on this tab counts against the broker's deadline. + const clickInput = { + ...(input as PreviewAutomationClickInput), + timeoutMs: Math.max(1, Math.floor(deadlineMs - performance.now())), + }; await recordAction(tab, "click", () => ServerBrowserPage.click(tab.page, clickInput, pointerFor(tab)), ); @@ -2692,7 +2816,11 @@ const make = Effect.gen(function* () { const request = event.request; if (request?.clear === "cookies") await tab.cdp.send("Network.clearBrowserCookies"); if (request?.clear === "cache") await tab.cdp.send("Network.clearBrowserCache"); - if (request?.hardReload) await tab.cdp.send("Page.reload", { ignoreCache: true }); + if (request?.hardReload) { + tab.pendingInitialRequestUrl = null; + await leaveBusyPage(tab, tab.cdp); + await tab.cdp.send("Page.reload", { ignoreCache: true }); + } }) .catch((cause: unknown) => runFork(Effect.logWarning("server preview could not apply a tab setting", { cause })), @@ -2830,21 +2958,42 @@ const make = Effect.gen(function* () { await applySetting(tab, setting); return; } - // A stalled navigation must not block a corrected URL or further viewer input. + // A stalled navigation must not block a corrected URL or further viewer input; only the + // bounded wait for a page script that never yields comes first. case "navigate": if (typeof message.url === "string") { const url = normalizePreviewUrl(message.url); + tab.pendingInitialRequestUrl = null; + await leaveBusyPage(tab, session); tab.control.trackUntilHandoff(tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS)); } return; case "history": + tab.pendingInitialRequestUrl = null; + await leaveBusyPage(tab, session); tab.control.trackUntilHandoff( num(message.delta) < 0 ? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS) : tab.page.goForward(VIEWER_NAVIGATION_OPTIONS), ); return; + case "stopLoading": { + const generation = tab.navigationGeneration; + tab.stoppedGeneration = generation; + await session.send("Page.stopLoading"); + // Stopping a committed document need not emit Playwright's load or requestfailed events. + settleStoppedNavigation(tab, generation); + return; + } case "reload": + tab.pendingInitialRequestUrl = null; + if (tab.crash) { + tab.control.trackUntilHandoff( + tab.page.goto(tab.crashedUrl ?? tab.crash.url, VIEWER_NAVIGATION_OPTIONS), + ); + return; + } + await leaveBusyPage(tab, session); if (message.ignoreCache === true) { await session.send("Page.reload", { ignoreCache: true }); return; diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 06c2d4983ea3..03088a173a81 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -14,6 +14,43 @@ const requirePlaywright = NodeModule.createRequire(import.meta.url); const loadPlaywright = () => requirePlaywright("playwright-core") as typeof import("playwright-core"); +const BADGE_STUB = `if (typeof WorkerNavigator === "function") for (const name of ["setAppBadge", "clearAppBadge"]) WorkerNavigator.prototype[name] = () => Promise.resolve();`; + +interface CdpSession { + send(method: string, params?: object): Promise; + createChildSession(...args: ReadonlyArray): CdpSession; +} + +// The headless shell binds no BadgeService for service workers and kills the renderer of a +// worker that calls navigator.setAppBadge. Playwright resumes each worker itself, so the stub +// can only go in ahead of that resume through its in-process session; without it nothing is stubbed. +const stubWorkerBadges = (client: Browser | BrowserContext) => { + const { _connection: connection } = client as unknown as { + readonly _connection?: { + readonly toImpl?: ( + client: unknown, + ) => + | { readonly _session?: CdpSession; readonly _browser?: { readonly _session?: CdpSession } } + | undefined; + }; + }; + const impl = connection?.toImpl?.(client); + const root = impl?._browser?._session ?? impl?._session; + if (!root) return; + const createChildSession = root.createChildSession.bind(root); + root.createChildSession = (...args) => { + const session = createChildSession(...args); + const send = session.send.bind(session); + session.send = (method, params) => { + if (method === "Runtime.runIfWaitingForDebugger") { + void send("Runtime.evaluate", { expression: BADGE_STUB }).catch(constVoid); + } + return send(method, params); + }; + return session; + }; +}; + interface Options { readonly profilesDir: string; readonly executable: () => Promise; @@ -127,6 +164,7 @@ export class ServerBrowserContexts { const browser = await this.launch(executable, (executablePath) => chromium.launch({ ...options, executablePath }), ); + stubWorkerBadges(browser); browser.on("disconnected", () => { if (this.browser === launched) this.browser = undefined; }); @@ -207,13 +245,15 @@ export class ServerBrowserContexts { await NodeFSP.mkdir(directory, { recursive: true }); const { executable, ...options } = await this.launchOptions(); const { chromium } = loadPlaywright(); - return this.launch(executable, (executablePath) => + const context = await this.launch(executable, (executablePath) => chromium.launchPersistentContext(directory, { ...options, ...contextOptions, executablePath, }), ); + stubWorkerBadges(context); + return context; } private profileDirectory(profileId: string) { diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index cabc2b99e67b..021cb70fb0ae 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -173,10 +173,22 @@ describe("server browser element refs", () => { const hidden = await failure("button"); expect(hidden.tag).toBe("PreviewAutomationTimeoutError"); expect(hidden.message).toBe( - 'The click may have happened; check the page before retrying. "button" still matches an element after the timeout.', + 'The click may have happened; check the page before retrying. "button" still matches an element after the timeout. Playwright last saw: element is not visible.', ); }); + it("gives a late target only the click's remaining time, not a fresh timeout", async () => { + await page.setContent(``); + await expect( + ServerBrowserPage.click(page, { selector: "#save", timeoutMs: 2_000 }), + ).rejects.toThrow(); + expect(await page.locator("body").getAttribute("data-clicked")).toBeNull(); + }); + it("does not retarget a removed ref to a replacement node", async () => { await page.setContent(""); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "original"); @@ -577,6 +589,98 @@ describe("server browser drag", () => { process.off("unhandledRejection", onUnhandled); } }); + + it("moves the mouse after dragstart, so pages that track a drag with mousemove see it", async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.setContent(` +
+
source
+
target
+
+ `); + + await ServerBrowserPage.drag(page, { source: "#source", target: "#target" }); + + const events = (await page.evaluate("window.events")) as Array; + expect(events.slice(0, 2)).toEqual(["dragstart", "mousemove"]); + expect(events.at(-1)).toBe("mouseup"); + } finally { + await browser.close(); + } + }); +}); + +describe("server browser click", () => { + it("clicks a target the page replaced while it was being scrolled into view", async () => { + let scrolls = 0; + const clicks: Array = []; + const page = { + locator: () => ({ + // Playwright resolves the node once; a page re-rendering it detaches that node. + scrollIntoViewIfNeeded: async () => { + if (scrolls++ === 0) { + throw new Error("locator.scrollIntoViewIfNeeded: Element is not attached to the DOM"); + } + }, + boundingBox: async () => ({ x: 0, y: 0, width: 20, height: 20 }), + click: async (options: unknown) => { + clicks.push(options); + }, + count: async () => 1, + }), + once: () => {}, + off: () => {}, + } as unknown as Page; + + await expect(ServerBrowserPage.click(page, { selector: "#save" })).resolves.toEqual({ + x: 10, + y: 10, + }); + expect(clicks).toHaveLength(1); + }); + + it("retries a replaced target only within the action's timeout", async () => { + vi.useFakeTimers(); + try { + const timeouts: Array = []; + const clicks: Array = []; + const detached = new Error( + "locator.scrollIntoViewIfNeeded: Element is not attached to the DOM", + ); + const page = { + locator: () => ({ + scrollIntoViewIfNeeded: async (options?: { timeout?: number }) => { + timeouts.push(options?.timeout); + vi.advanceTimersByTime(60); + throw detached; + }, + click: async (options: unknown) => { + clicks.push(options); + }, + }), + } as unknown as Page; + + await expect( + ServerBrowserPage.click(page, { selector: "#save", timeoutMs: 100 }), + ).rejects.toBe(detached); + expect(timeouts).toEqual([100, 40]); + expect(clicks).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); }); describe("server browser evaluate", () => { @@ -613,6 +717,14 @@ describe("server browser evaluate", () => { }); }); +describe("server browser unresponsive script", () => { + it("leaves a page that answers alone", async () => { + const send = vi.fn(async (_method: string) => ({})); + await ServerBrowserPage.stopUnresponsiveScript({ send } as unknown as CDPSession); + expect(send.mock.calls.map(([method]) => method)).toEqual(["Runtime.evaluate"]); + }); +}); + describe("server browser wait", () => { it("ends at its timeout when a check never answers", async () => { // As on a desktop tab whose Playwright utility world never answers. diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 0d5ed3d1ff89..d3f7bf1d7c3f 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -22,6 +22,7 @@ import { constVoid } from "effect/Function"; import * as Schema from "effect/Schema"; import type { CDPSession, ConsoleMessage, Frame, Locator, Page } from "playwright-core"; import * as NodeCrypto from "node:crypto"; +import * as NodeUtil from "node:util"; import { BrowserControlInterrupted } from "./SessionControl.ts"; const isBrowserControlInterrupted = Schema.is(BrowserControlInterrupted); @@ -62,7 +63,7 @@ export const toOperationError = ( if (cause instanceof Error && cause.name === "TimeoutError") { return new ServerBrowserOperationError({ tag: "PreviewAutomationTimeoutError", - message: firstLine, + message: withActionabilityReason(firstLine, cause), detail: timeoutMs === undefined ? undefined : { timeoutMs }, }); } @@ -84,6 +85,17 @@ export const toOperationError = ( }); }; +/** Playwright puts why an element was not actionable in its call log, below the first line. */ +const withActionabilityReason = (message: string, cause: Error) => { + // Playwright dims call-log lines with ANSI escapes when colour is on. + const reason = NodeUtil.stripVTControlCharacters(cause.message) + .split("\n") + .map((line) => line.trim()) + .findLast((line) => line.startsWith("- element is ")) + ?.slice(2); + return reason === undefined ? message : `${message} Playwright last saw: ${reason}.`; +}; + const DEFAULT_TIMEOUT_MS = 15_000; const TARGET_COUNT_MS = 250; const MAX_TARGET_CHARS = 200; @@ -147,15 +159,32 @@ export type PointerReporter = ( ) => Promise; const noPointer: PointerReporter = async () => {}; +// Locator.scrollIntoViewIfNeeded resolves its node once and fails when the page replaces it; the +// input actions after it re-resolve, so the scroll does too, within the same timeout. +const DETACHED = /^locator\.scrollIntoViewIfNeeded: Element is not attached to the DOM(?:\n|$)/; + +const scrollIntoView = async (locator: Locator, timeout: number) => { + const deadline = Date.now() + timeout; + for (;;) { + try { + return await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, deadline - Date.now()) }); + } catch (cause) { + if (!(cause instanceof Error && DETACHED.test(cause.message)) || Date.now() >= deadline) { + throw cause; + } + } + } +}; + const targetPoint = async ( page: Page, locator: Locator | null, input: { readonly x?: number | undefined; readonly y?: number | undefined }, - timeout: number, + timeout: () => number, ) => { if (locator === null) return { x: input.x ?? 0, y: input.y ?? 0 }; - await locator.scrollIntoViewIfNeeded({ timeout }); - const box = await locator.boundingBox({ timeout }); + await scrollIntoView(locator, timeout()); + const box = await locator.boundingBox({ timeout: timeout() }); if (box) return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; const viewport = page.viewportSize(); return { x: (viewport?.width ?? 0) / 2, y: (viewport?.height ?? 0) / 2 }; @@ -694,10 +723,12 @@ export const click = async ( input: PreviewAutomationClickInput, pointer: PointerReporter = noPointer, ): Promise<{ readonly x: number; readonly y: number }> => { - const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + // One deadline across steps keeps the reply inside the broker's grace; Playwright reads 0 as none. + const deadline = Date.now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const remaining = () => Math.max(1, deadline - Date.now()); const locator = targetLocator(page, input); try { - return await clickTarget(page, locator, input, timeout, pointer); + return await clickTarget(page, locator, input, remaining, pointer); } catch (cause) { throw locator === null ? cause : await namedTargetTimeout(locator, input, cause); } @@ -733,10 +764,11 @@ const namedTargetTimeout = async ( : matches === 0 ? `The click may have happened and removed its target or navigated; check the page before retrying. No element matches ${target} after the timeout; take a fresh snapshot and use its locator.` : `The click may have happened; check the page before retrying. ${target} still matches an element after the timeout.`; + const message = withActionabilityReason(reason, cause); return new ServerBrowserOperationError({ tag: "PreviewAutomationTimeoutError", - message: reason, - detail: { reason }, + message, + detail: { reason: message }, cause, }); }; @@ -745,7 +777,7 @@ const clickTarget = async ( page: Page, locator: Locator | null, input: PreviewAutomationClickInput, - timeout: number, + timeout: () => number, pointer: PointerReporter, ) => { const point = await targetPoint(page, locator, input, timeout); @@ -754,7 +786,7 @@ const clickTarget = async ( const clicked = locator === null ? page.mouse.click(point.x, point.y, options) - : locator.click({ ...options, timeout }); + : locator.click({ ...options, timeout: timeout() }); let onDialog = constVoid; const dialogOpened = new Promise<"dialog">((resolve) => { onDialog = () => resolve("dialog"); @@ -835,7 +867,7 @@ export const hover = async ( ) => { const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const locator = targetLocator(page, input); - await pointer(await targetPoint(page, locator, input, timeout), "move"); + await pointer(await targetPoint(page, locator, input, () => timeout), "move"); if (locator === null) { await page.mouse.move(input.x ?? 0, input.y ?? 0); return; @@ -886,9 +918,10 @@ export const drag = async ( const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const source = targetLocator(page, { locator: input.source })!; const target = targetLocator(page, { locator: input.target })!; - await pointer(await targetPoint(page, source, {}, timeout), "move"); + await pointer(await targetPoint(page, source, {}, () => timeout), "move"); // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. - const dragFailure = source.dragTo(target, { timeout }).then( + // Chromium delivers a single move before dragstart; steps give the page mousemoves during the drag. + const dragFailure = source.dragTo(target, { timeout, steps: 5 }).then( () => null, (error: unknown) => ({ error }), ); @@ -920,6 +953,26 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); }; +const UNRESPONSIVE_SCRIPT_PROBE_MS = 1_000; + +/** + * A same-site navigation reuses the renderer, so it never commits while a page script holds it. + * Call before starting the navigation: Chrome does not deliver a terminate once one is pending. + */ +export const stopUnresponsiveScript = async (cdp: CDPSession, budgetMs = Infinity) => { + let timer: ReturnType | undefined; + const answered = cdp.send("Runtime.evaluate", { expression: "0" }).then( + () => true, + () => true, + ); + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.min(budgetMs, UNRESPONSIVE_SCRIPT_PROBE_MS)); + }); + const responsive = await Promise.race([answered, expired]).finally(() => clearTimeout(timer)); + // Not awaited: a renderer blocked on something other than script may never acknowledge it. + if (!responsive) void cdp.send("Runtime.terminateExecution").catch(constVoid); +}; + /** Terminate expired page scripts so a hung expression cannot block the tab's control queue. */ export const evaluate = async ( cdp: CDPSession, @@ -1102,7 +1155,8 @@ export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => window.__t3Recorder = { async frame(base64, cssWidth) { if (tooLarge) return false; - const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); + // A per-character atob copy costs ~30 ms on a 3200x1800 frame and throttles recordings. + const bytes = Uint8Array.fromBase64(base64); const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); if (tooLarge) { bitmap.close(); return false; } if (!recorder) { diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index cc2f7d6f15cd..1128c66f6a8c 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -65,6 +65,7 @@ const mutations = [ { type: "navigate", url: "https://example.com" }, { type: "history", delta: -1 }, { type: "reload" }, + { type: "stopLoading" }, { type: "probe", x: 10, y: 10 }, { type: "takeControl" }, { type: "releaseControl" }, diff --git a/apps/server/src/process/NonBlockingSpawner.test.ts b/apps/server/src/process/NonBlockingSpawner.test.ts new file mode 100644 index 000000000000..81563cbab70d --- /dev/null +++ b/apps/server/src/process/NonBlockingSpawner.test.ts @@ -0,0 +1,200 @@ +// @effect-diagnostics nodeBuiltinImport:off - the spawner's checks are node:fs/promises calls, which the test spies on. +import * as NodeFSP from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Path from "effect/Path"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as NonBlockingSpawner from "./NonBlockingSpawner.ts"; + +vi.mock("node:fs/promises", { spy: true }); + +const hostPath = process.env.PATH; + +const wrapped = NonBlockingSpawner.layer.pipe(Layer.provideMerge(NodeServices.layer)); + +/** What a caller sees of a command: its output, or the error of a spawn that failed. */ +const outcome = (command: ChildProcess.Command) => + ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.string(command)).pipe( + Effect.match({ + onSuccess: (output) => ({ output }), + onFailure: (error) => ({ reason: error.reason._tag, message: error.message }), + }), + ); + +describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("NonBlockingSpawner", () => { + it.live("gives callers what the native spawner gives them", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.realPath(yield* fileSystem.makeTempDirectoryScoped()); + for (const [file, mode] of [ + ["stale/tool", 0o644], + ["fresh/tool", 0o755], + ["target/bin/tool", 0o755], + // GNU env reads a lone `-` as -i and `NAME=value` as an assignment, even after `--`. + ["fresh/-", 0o755], + ["fresh/launch=stable", 0o755], + ] as const) { + yield* fileSystem.makeDirectory(`${root}/${file.slice(0, file.lastIndexOf("/"))}`, { + recursive: true, + }); + yield* fileSystem.writeFileString(`${root}/${file}`, `#!/bin/sh\necho ${file} "$@"\n`); + yield* fileSystem.chmod(`${root}/${file}`, mode); + } + yield* fileSystem.makeDirectory(`${root}/target/subdir`); + yield* fileSystem.symlink(`${root}/target/subdir`, `${root}/link`); + const PATH = `${root}/stale:${root}/fresh`; + + const commands = { + "enters the cwd": ChildProcess.make("pwd", ["-P"], { cwd: root }), + "keeps argv": ChildProcess.make( + "printf", + ["%s\\n", "-n", "--", "a b", `it's "quoted"`, "$HOME", "; exit 3", "", "A=b"], + { cwd: root }, + ), + // dash would drop the exported function and add PWD and OLDPWD. + "keeps the environment": ChildProcess.make("env", [], { + cwd: root, + env: { PATH: hostPath, A: "1", "BASH_FUNC_x%%": "() { echo exported\n}" }, + }), + "skips a non-executable PATH entry": ChildProcess.make("tool", [], { env: { PATH } }), + "resolves `..` in PATH after a symlink": ChildProcess.make("tool", [], { + env: { PATH: `${root}/link/../bin` }, + }), + "searches PATH relative to the cwd": ChildProcess.make("tool", [], { + cwd: root, + env: { PATH: "fresh" }, + }), + "a command named -": ChildProcess.make("-", [], { cwd: root, env: { PATH } }), + "a command containing =": ChildProcess.make("launch=stable", [], { env: { PATH } }), + "shell mode": ChildProcess.make('pwd -P; echo "$0" "a b"', [], { cwd: root, shell: true }), + "a pipeline": ChildProcess.make("printf", ["a\\nb\\n"], { cwd: root }).pipe( + ChildProcess.pipeTo(ChildProcess.make("tail", ["-n", "1"])), + ), + "a missing command": ChildProcess.make("t3code-missing-command", ["--version"]), + "a missing command in a cwd": ChildProcess.make("t3code-missing-command", [], { + cwd: root, + }), + "a non-executable command": ChildProcess.make("tool", [], { + env: { PATH: `${root}/stale` }, + }), + "a missing explicit path": ChildProcess.make("./missing", [], { cwd: root }), + "a non-executable explicit path": ChildProcess.make(`${root}/stale/tool`, []), + "a missing cwd": ChildProcess.make("true", [], { cwd: `${root}/missing` }), + // The native check sees `missing`, although the path resolves to an existing directory. + "a cwd through a missing directory": ChildProcess.make("true", [], { + cwd: `${root}/missing/..`, + }), + }; + for (const [name, command] of Object.entries(commands)) { + const native = yield* outcome(command).pipe(Effect.provide(NodeServices.layer)); + expect(yield* outcome(command).pipe(Effect.provide(wrapped)), name).toEqual(native); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("hands the native spawner only `env`, never a cwd or a command to look up", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + const received: Array = []; + const record = (command: ChildProcess.Command): void => { + if (command._tag === "StandardCommand") received.push(command); + else [command.left, command.right].forEach(record); + }; + const recording = Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.map(ChildProcessSpawner.ChildProcessSpawner, (native) => + ChildProcessSpawner.make((command) => { + record(command); + return native.spawn(command); + }), + ), + ).pipe(Layer.provideMerge(NodeServices.layer)); + const commands = [ + ChildProcess.make("true", [], { cwd: directory }), + ChildProcess.make("true", []), + ChildProcess.make("/bin/true", []), + ChildProcess.make("t3code-missing-command", []), + ChildProcess.make("true", [], { cwd: directory, shell: true }), + ChildProcess.make("printf", ["a"], { cwd: directory }).pipe( + ChildProcess.pipeTo(ChildProcess.make("cat", [], { cwd: directory })), + ), + ]; + yield* Effect.forEach(commands, (command) => + ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.exitCode(command)).pipe( + Effect.exit, + ), + ).pipe(Effect.provide(NonBlockingSpawner.layer.pipe(Layer.provide(recording)))); + + expect(received.map((command) => [command.command, command.options.cwd])).toEqual( + Array.from({ length: 6 }, () => ["/usr/bin/env", undefined]), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("checks at most two paths at once, however many directories hang", () => + Effect.gen(function* () { + const allArrived = yield* Deferred.make(); + let arrivals = 0; + // Each spawn resolves its cwd, then starts its check within microtasks. + const arrivalPath = Layer.effect( + Path.Path, + Effect.map(Path.Path, (path) => ({ + ...path, + resolve: (...segments: ReadonlyArray) => { + if (++arrivals === 3) setImmediate(() => Deferred.doneUnsafe(allArrived, Effect.void)); + return path.resolve(...segments); + }, + })), + ); + const hung: Array<() => void> = []; + let releasing = false; + let running = 0; + let mostRunning = 0; + using _access = vi.spyOn(NodeFSP, "access").mockImplementation( + () => + new Promise((_resolve, reject) => { + mostRunning = Math.max(mostRunning, ++running); + const fail = () => { + running--; + reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + }; + if (releasing) fail(); + else hung.push(fail); + }), + ); + const spawns = yield* Effect.forEach( + ["a", "b", "c"], + (name) => + ChildProcessSpawner.ChildProcessSpawner.use((spawner) => + spawner.exitCode(ChildProcess.make("true", [], { cwd: `/t3code-hung/${name}` })), + ).pipe(Effect.flip), + { concurrency: "unbounded" }, + ).pipe( + Effect.provide( + NonBlockingSpawner.layer.pipe( + Layer.provide(arrivalPath), + Layer.provide(NodeServices.layer), + ), + ), + Effect.forkChild, + ); + yield* Deferred.await(allArrived); + expect(mostRunning).toBe(2); + releasing = true; + for (const fail of hung.splice(0)) fail(); + yield* Fiber.join(spawns); + expect(mostRunning).toBe(2); + }), + ); +}); diff --git a/apps/server/src/process/NonBlockingSpawner.ts b/apps/server/src/process/NonBlockingSpawner.ts new file mode 100644 index 000000000000..0807b287cb9c --- /dev/null +++ b/apps/server/src/process/NonBlockingSpawner.ts @@ -0,0 +1,178 @@ +// @effect-diagnostics nodeBuiltinImport:off - execvp's X_OK test has no Effect FileSystem equivalent. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; + +import { envTrampoline, supportsEnvChdir } from "@t3tools/shared/envTrampoline"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as CommandLine from "effect/process/CommandLine"; + +// libuv's _PATH_DEFPATH, searched when the child environment has no PATH. +const DEFAULT_PATH = "/usr/bin:/bin"; + +// A check on a hung mount holds a libuv threadpool thread until the mount answers, and the +// pool's four threads also compress every WebSocket frame. Checks therefore run one at a time +// per directory and two at a time overall; the rest wait without holding a thread. +const CHECK_LIMIT = 2; +const directoryTails = new Map>(); +const waiting: Array<() => void> = []; +let running = 0; + +const access = async (path: string, mode: number) => { + if (running < CHECK_LIMIT) running++; + else await new Promise((resume) => waiting.push(resume)); + try { + await NodeFSP.access(path, mode); + return undefined; + } catch (error) { + return error as NodeJS.ErrnoException; + } finally { + // A waiter takes over the slot. + const next = waiting.shift(); + if (next === undefined) running--; + else next(); + } +}; + +/** The errno error of `access(path, mode)`, or undefined when it passes. */ +const check = (directory: string, path: string, mode: number) => { + const result = (directoryTails.get(directory) ?? Promise.resolve()).then(() => + access(path, mode), + ); + directoryTails.set(directory, result); + void result.then(() => { + if (directoryTails.get(directory) === result) directoryTails.delete(directory); + }); + return Effect.promise(() => result); +}; + +// The PATH the child searches, from the environment Effect's Node spawner gives it. +const childPath = ({ env, extendEnv }: ChildProcess.CommandOptions) => { + if (env === undefined) return process.env.PATH; + if (!Object.hasOwn(env, "PATH")) return extendEnv === true ? process.env.PATH : undefined; + const value = env.PATH; + return value === undefined || !Redacted.isRedacted(value) ? value : Redacted.value(value); +}; + +// The spawn error Effect's Node spawner reports when execvp fails with `code`. +const spawnFailure = (command: ChildProcess.StandardCommand, code: "ENOENT" | "EACCES") => { + const syscall = `spawn ${command.command}`; + return PlatformError.systemError({ + _tag: code === "ENOENT" ? "NotFound" : "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: CommandLine.display([command.command, ...command.args], { format: "posix" }), + syscall, + cause: Object.assign(new Error(`${syscall} ${code}`), { + errno: code === "ENOENT" ? -2 : -13, + code, + syscall, + path: command.command, + spawnargs: command.args.map((arg) => (Redacted.isRedacted(arg) ? "" : arg)), + }), + }); +}; + +// The error of the `FileSystem.access(cwd)` Effect's Node spawner runs before it spawns. +const cwdFailure = (cwd: string, cause: NodeJS.ErrnoException) => + PlatformError.systemError({ + _tag: + cause.code === "ENOENT" + ? "NotFound" + : cause.code === "EACCES" || cause.code === "EPERM" + ? "PermissionDenied" + : cause.code === "ENOTDIR" || cause.code === "ELOOP" + ? "BadResource" + : "Unknown", + module: "FileSystem", + method: "access", + pathOrDescriptor: cwd, + syscall: cause.syscall, + cause, + }); + +/** + * Fails as a native spawn does when the command is missing or not executable: callers read + * that error to tell a CLI that is not installed from one that failed. execvp reports EACCES + * when a candidate exists but cannot be run, and ENOENT otherwise. + */ +const findExecutable = Effect.fnUntraced(function* ( + command: ChildProcess.StandardCommand, + cwd: string | undefined, +) { + // Joined as text: the kernel resolves `..` after a symlink differently from a normalized path. + const fromCwd = (path: string) => + cwd === undefined || path.startsWith("/") ? path : `${cwd}/${path}`; + const candidates = command.command.includes("/") + ? [fromCwd(command.command)] + : (childPath(command.options) ?? DEFAULT_PATH) + .split(":") + .map((entry) => `${fromCwd(entry || ".")}/${command.command}`); + let denied = false; + for (const candidate of candidates) { + const error = yield* check( + candidate.slice(0, candidate.lastIndexOf("/")) || "/", + candidate, + NodeFS.constants.X_OK, + ); + // Any other errno is left for `env` to report through its exit code. + if (error === undefined || (error.code !== "ENOENT" && error.code !== "EACCES")) return; + denied ||= error.code === "EACCES"; + } + return yield* spawnFailure(command, denied ? "EACCES" : "ENOENT"); +}); + +const make = Effect.gen(function* () { + const native = yield* ChildProcessSpawner.ChildProcessSpawner; + if ((yield* HostProcess.Platform) !== "linux") return native; + if (!(yield* Effect.promise(supportsEnvChdir))) return native; + const path = yield* Path.Path; + + // Runs inside the NonBlockingSpawner.spawn span. + const prepareStandard = Effect.fnUntraced(function* (input: ChildProcess.StandardCommand) { + const { cwd: givenCwd, shell, ...options } = input.options; + // Effect rejects a shell command with arguments, and a NUL, itself. + if ((shell && input.args.length > 0) || input.command.includes("\0")) return input; + // Node's shell mode on Unix, spelled out so the trampoline can run it. + const command = shell + ? ChildProcess.make(typeof shell === "string" ? shell : "/bin/sh", ["-c", input.command]) + : input; + // Checked as given and entered resolved, as Effect's Node spawner does. + const cwd = givenCwd === undefined ? undefined : path.resolve(givenCwd); + const trampoline = envTrampoline(command.command, command.args, cwd); + if (trampoline === undefined) return input; + if (givenCwd !== undefined && cwd !== undefined) { + const error = yield* check(cwd, givenCwd, NodeFS.constants.F_OK); + if (error !== undefined) return yield* cwdFailure(givenCwd, error); + } + yield* findExecutable(ChildProcess.make(command.command, command.args, options), cwd); + return ChildProcess.make(trampoline.command, trampoline.args, options); + }); + + const prepare = ( + command: ChildProcess.Command, + ): Effect.Effect => + command._tag === "StandardCommand" + ? prepareStandard(command) + : Effect.map(Effect.all([prepare(command.left), prepare(command.right)]), ([left, right]) => + ChildProcess.pipeTo(left, right, command.options), + ); + + return ChildProcessSpawner.make( + Effect.fn("NonBlockingSpawner.spawn")(function* (command) { + return yield* native.spawn(yield* prepare(command)); + }), + ); +}); + +/** + * On Linux with GNU `env`, replaces the provided `ChildProcessSpawner` with one that never + * hands libuv a cwd or a PATH search; elsewhere it passes the provided one through. + */ +export const layer = Layer.effect(ChildProcessSpawner.ChildProcessSpawner, make); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index b05a0a0504cd..4378a7fde847 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -92,6 +92,7 @@ function candidate( { role: "assistant" as const, text: "Fixed", createdAt: timestamp }, ], }, + worktree: null as { path: string; branch: string | null } | null, } satisfies AgentSessionScanner.AgentSessionRecentThread; } @@ -304,6 +305,7 @@ it.effect( ); const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); unowned.thread.omittedMessageCount = 1; + unowned.worktree = { path: "/workspace/project/.claude/worktrees/feat", branch: "feat" }; const unownedPi = candidate(PI_MODEL_SELECTION, "unowned-pi", "pi"); const importer = yield* makeImporter([ active, @@ -356,6 +358,8 @@ it.effect( expect(notices).toEqual([]); } expect(records.thread.historyOrigin).toBe("v1_import"); + expect(records.thread.worktreePath).toBe(source.worktree?.path ?? null); + expect(records.thread.branch).toBe(source.worktree?.branch ?? null); expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(nativeId(source)); expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ @@ -364,7 +368,10 @@ it.effect( source.source.provider === "claudeAgent" ? { threadId, resume: source.source.providerSessionId } : { threadId: nativeId(source) }, - runtimePayload: { importedTranscripts: [source.source] }, + runtimePayload: { + cwd: source.worktree?.path ?? "/workspace/project", + importedTranscripts: [source.source], + }, }); } }).pipe(Effect.scoped, Effect.provide(persistence)), diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 9c80ebf1049b..6200fc11188f 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -309,7 +309,7 @@ const make = Effect.gen(function* () { thread.source === "claudeAgent" ? { threadId, resume: thread.providerSessionId } : { threadId: nativeThreadId }, - runtimePayload: { cwd: project.workspaceRoot }, + runtimePayload: { cwd: outcome.worktree?.path ?? project.workspaceRoot }, }, { onConflict: "ignore" }, ); @@ -362,8 +362,8 @@ const make = Effect.gen(function* () { modelSelection: { instanceId: thread.providerInstanceId, model }, runtimeMode, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - branch: null, - worktreePath: null, + branch: outcome.worktree?.branch ?? null, + worktreePath: outcome.worktree?.path ?? null, linkedPullRequest: null, branchPullRequest: null, activeProviderThreadId: providerThreadId, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 30db62847e8e..6dac5a1b0b6c 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -5,6 +5,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodeSqlite from "node:sqlite"; +import * as NodeZlib from "node:zlib"; import { describe, expect, it } from "@effect/vitest"; import { AgentSessionImportSource, @@ -174,6 +175,21 @@ const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(fu yield* fileSystem.utimes(input.filePath, seconds, seconds); }); +const writeCompressedTranscript = Effect.fn("AgentSessionScanner.test.writeCompressedTranscript")( + function* (input: { + readonly filePath: string; + readonly contents: string; + readonly mtimeMs: number; + }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFile(input.filePath, NodeZlib.zstdCompressSync(input.contents)); + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); + }, +); + const claudeSessionLine = (cwd: string) => `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; @@ -1088,6 +1104,70 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports a checkout's sessions from the linked worktrees the scan counts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + yield* fileSystem.writeFileString(path.join(repo, ".git", "config"), "[core]\n"); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "feature"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + path.join(worktreeGitDir, "HEAD"), + "ref: refs/heads/feature\n", + ); + const worktree = path.join(repo, ".claude", "worktrees", "feature"); + yield* fileSystem.makeDirectory(worktree, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${worktreeGitDir}\n`, + ); + + for (const [index, [cwd, sessionId]] of [ + [repo, "at-root"], + [worktree, "in-worktree"], + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, `${sessionId}.jsonl`), + contents: `${JSON.stringify({ + type: "user", + cwd, + sessionId, + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`, + mtimeMs: nowMs - (index + 1) * 60 * 60 * 1000, + }); + } + + const input = { claudeHomePath, codexHomePath }; + const { candidates } = yield* runScan(input); + expect(candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ + { path: repo, threadCount: 2 }, + ]); + const outcomes = yield* runRecentThreadOutcomes({ ...input, workspaceRoot: repo }); + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? [{ sessionId: outcome.thread.providerSessionId, worktree: outcome.worktree }] + : [], + ), + ).toEqual([ + { sessionId: "at-root", worktree: null }, + { sessionId: "in-worktree", worktree: { path: worktree, branch: "feature" } }, + ]); + }), + ); + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1244,6 +1324,32 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const result = yield* runScan({ claudeHomePath, codexHomePath }); + expect(result.candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ + { path: workspace, threadCount: 1 }, + ]); + }), + ); + it.effect("discovers projects whose Codex rollouts are zstd-compressed", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeCompressedTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "05", + "03", + "rollout-2026-05-03T00-00-00-019a5f00-0000-7000-8000-00000000000b.jsonl.zst", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + expect(result.candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ { path: workspace, threadCount: 1 }, ]); @@ -1252,6 +1358,66 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); describe("recentThreads", () => { + it.effect("imports Codex conversations stored only as zstd-compressed rollouts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeCompressedTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "20", + "rollout-2026-08-20T10-00-00-019a5f00-0000-7000-8000-00000000000c.jsonl.zst", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "019a5f00-0000-7000-8000-00000000000c", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-20T10:00:00.000Z", + payload: { type: "user_message", message: "Compressed prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-20T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Compressed answer" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 4 * 24 * 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect( + threads.map((thread) => ({ + id: thread.providerSessionId, + messages: thread.messages.map((message) => message.text), + })), + ).toEqual([ + { + id: "019a5f00-0000-7000-8000-00000000000c", + messages: ["Compressed prompt", "Compressed answer"], + }, + ]); + }), + ); + it.effect("imports recent Claude, Codex, and Pi sessions for the selected project only", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index d9ca41b8f26b..0de2e84b1f6e 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1,5 +1,6 @@ import * as NodeOS from "node:os"; import * as NodeSqlite from "node:sqlite"; +import * as NodeZlib from "node:zlib"; import { AgentSessionImportSource, @@ -17,11 +18,13 @@ import { import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as NodeStream from "@effect/platform-node/NodeStream"; 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 Pull from "effect/Pull"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; @@ -177,7 +180,11 @@ const decodeCodexHistoryBase = Schema.decodeUnknownOption( ); // Original rollouts end in their thread ID, rewound ones in `_`. const CODEX_ROLLOUT_ID_PATTERN = - /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl$/i; + /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl(?:\.zst)?$/i; +// Codex zstd-compresses older rollouts; their byte limits and offsets count decompressed bytes. +const COMPRESSED_TRANSCRIPT_SUFFIX = ".jsonl.zst"; +const isCompressedTranscript = (filePath: string) => + filePath.endsWith(COMPRESSED_TRANSCRIPT_SUFFIX); // Codex records a chat's project only here; moving a chat leaves its rollout cwd unchanged. const CODEX_STATE_DATABASE = "state_5.sqlite"; const decodeCodexSessionIndexEntry = Schema.decodeUnknownOption( @@ -216,6 +223,8 @@ export type AgentSessionRecentThread = readonly _tag: "Importable"; readonly thread: AgentSessionThread; readonly source: AgentSessionImportSource; + /** The root's linked worktree the session ran in, or null when it ran in the root. */ + readonly worktree: { readonly path: string; readonly branch: string | null } | null; } | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } @@ -861,6 +870,33 @@ export const make = Effect.gen(function* () { const statOption = (target: string) => fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none)); + const transcriptChunkReader = Effect.fn("AgentSessionScanner.transcriptChunkReader")(function* ( + file: FileSystem.File, + filePath: string, + ) { + if (!isCompressedTranscript(filePath)) return (size: number) => file.readAlloc(size); + const pull = yield* Stream.toPull( + fileSystem + .stream(filePath) + .pipe(NodeStream.pipeThroughSimple(() => NodeZlib.createZstdDecompress())), + ); + let pending: Array = []; + let done = false; + // Untraced: called once per chunk inside the caller's traced read. + return Effect.fnUntraced(function* (size: number) { + while (pending.length === 0) { + if (done) return Option.none(); + const next = yield* pull.pipe(Pull.catchDone(() => Effect.undefined)); + if (next === undefined) done = true; + else pending = [...next]; + } + const chunk = pending.shift(); + if (chunk === undefined) return Option.none(); + if (chunk.byteLength > size) pending.unshift(chunk.subarray(size)); + return Option.some(chunk.subarray(0, size)); + }); + }); + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( target: string, knownStats?: FileSystem.File.Info, @@ -945,19 +981,24 @@ export const make = Effect.gen(function* () { | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } | { readonly _tag: "Worktree"; + readonly gitDir: string; readonly mainCheckout: string | null; readonly git: AgentSessionProjectGit | null; } | { readonly _tag: "NotGit" }; const identities = new Map(); - const repositories: Array<{ directory: string; gitDir: string; linkedWorktree: boolean }> = []; + const repositories: Array<{ + directory: string; + gitDir: string; + worktreeGitDir: string | null; + }> = []; const pointers: Array<{ directory: string; gitPath: string }> = []; for (const directory of directories) { const gitPath = path.join(directory, ".git"); const gitStats = yield* statOption(gitPath); if (Option.isNone(gitStats)) identities.set(directory, { _tag: "NotGit" }); else if (gitStats.value.type === "Directory") { - repositories.push({ directory, gitDir: gitPath, linkedWorktree: false }); + repositories.push({ directory, gitDir: gitPath, worktreeGitDir: null }); } else pointers.push({ directory, gitPath }); } @@ -972,7 +1013,7 @@ export const make = Effect.gen(function* () { const gitDir = path.resolve(directory, target); if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) { linkedWorktrees.push({ directory, gitDir }); - } else repositories.push({ directory, gitDir, linkedWorktree: false }); + } else repositories.push({ directory, gitDir, worktreeGitDir: null }); } const commonDirTexts = yield* readGitFiles( @@ -981,19 +1022,19 @@ export const make = Effect.gen(function* () { for (const { directory, gitDir } of linkedWorktrees) { const commonDir = (commonDirTexts.get(path.join(gitDir, "commondir")) ?? "").trim(); if (commonDir.length === 0) { - identities.set(directory, { _tag: "Worktree", mainCheckout: null, git: null }); + identities.set(directory, { _tag: "Worktree", gitDir, mainCheckout: null, git: null }); } else repositories.push({ directory, gitDir: path.resolve(gitDir, commonDir), - linkedWorktree: true, + worktreeGitDir: gitDir, }); } const configTexts = yield* readGitFiles( repositories.map(({ gitDir }) => path.join(gitDir, "config")), ); - for (const { directory, gitDir, linkedWorktree } of repositories) { + for (const { directory, gitDir, worktreeGitDir } of repositories) { const configText = configTexts.get(path.join(gitDir, "config")) ?? ""; const originUrl = parseOriginUrlFromGitConfig(configText); const git: AgentSessionProjectGit = { @@ -1002,9 +1043,10 @@ export const make = Effect.gen(function* () { repository: repositoryPathFromRemoteUrl(originUrl), }; // Bare repositories have no main checkout, so offer their linked worktrees independently. - if (linkedWorktree && !isBareGitConfig(configText)) { + if (worktreeGitDir !== null && !isBareGitConfig(configText)) { identities.set(directory, { _tag: "Worktree", + gitDir: worktreeGitDir, // Only a common dir named .git sits inside its main checkout, and an // unreadable config can't rule out a bare repository. mainCheckout: @@ -1018,6 +1060,61 @@ export const make = Effect.gen(function* () { return identities; }); + /** Maps each directory to the project it counts toward: a linked worktree counts toward its main checkout. */ + const resolveDirectoryProjects = Effect.fn("AgentSessionScanner.resolveDirectoryProjects")( + function* (resolvedDirectories: ReadonlyArray) { + // Stat every directory first so one batch reads all git identities. + const directories = new Map>(); + for (const resolved of resolvedDirectories) { + if (directories.has(resolved)) continue; + directories.set(resolved, yield* projectDirectoryStats(resolved)); + } + const directoryGitIdentities = yield* readGitIdentities( + Array.from(directories).flatMap(([directory, stats]) => + Option.isSome(stats) ? [directory] : [], + ), + ); + const projects = new Map< + string, + { + readonly key: string; + readonly path: string; + readonly git: AgentSessionProjectGit | null; + readonly worktreeGitDir: string | null; + } | null + >(); + for (const [resolved, stats] of directories) { + const gitIdentity = directoryGitIdentities.get(resolved); + if (Option.isNone(stats) || gitIdentity === undefined) { + projects.set(resolved, null); + } else if (gitIdentity._tag === "Worktree") { + const { mainCheckout } = gitIdentity; + const mainStats = + mainCheckout === null ? Option.none() : yield* projectDirectoryStats(mainCheckout); + projects.set( + resolved, + mainCheckout !== null && Option.isSome(mainStats) + ? { + key: yield* directoryIdentity(mainCheckout, mainStats.value), + path: mainCheckout, + git: gitIdentity.git, + worktreeGitDir: gitIdentity.gitDir, + } + : null, + ); + } else { + projects.set(resolved, { + key: yield* directoryIdentity(resolved, stats.value), + path: resolved, + git: gitIdentity._tag === "Repository" ? gitIdentity.git : null, + worktreeGitDir: null, + }); + } + } + return projects; + }, + ); + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( source: AgentSessionSource, transcript: TranscriptCandidate, @@ -1037,11 +1134,15 @@ export const make = Effect.gen(function* () { fileSystem.open(transcript.filePath, { flag: "r" }).pipe( Effect.flatMap((file) => Effect.gen(function* () { + const readChunk = yield* transcriptChunkReader(file, transcript.filePath); + const size = isCompressedTranscript(transcript.filePath) + ? Number.POSITIVE_INFINITY + : transcript.identity.size; const decoder = new TextDecoder(); let remaining = ""; let bytesRead = 0; let recordsRead = 0; - const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.identity.size); + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, size); const reserveRecord = () => { if ( recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || @@ -1073,7 +1174,7 @@ export const make = Effect.gen(function* () { ); budget.operationsRemaining -= 1; budget.bytesRemaining -= readSize; - const next = yield* file.readAlloc(readSize); + const next = yield* readChunk(readSize); if (Option.isNone(next)) { return readLastRecord(); } @@ -1090,7 +1191,7 @@ export const make = Effect.gen(function* () { } } - if (bytesRead < transcript.identity.size) { + if (bytesRead < size) { budget.truncated = true; return null; } @@ -1107,8 +1208,12 @@ export const make = Effect.gen(function* () { source: AgentSessionSource, prefix?: { readonly endByteOffset: number; readonly historyBytes: number }, ) { - const readBytes = prefix?.endByteOffset ?? expected.size; - if (readBytes > expected.size || readBytes > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + const compressed = isCompressedTranscript(filePath); + const readBytes = + prefix?.endByteOffset ?? (compressed ? MAX_IMPORTED_TRANSCRIPT_BYTES : expected.size); + if ((!compressed && readBytes > expected.size) || readBytes > MAX_IMPORTED_TRANSCRIPT_BYTES) { + return null; + } return yield* Effect.scoped( fileSystem.open(filePath, { flag: "r" }).pipe( @@ -1117,6 +1222,7 @@ export const make = Effect.gen(function* () { if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { return null; } + const readChunk = yield* transcriptChunkReader(file, filePath); const records: Array = []; const claudeHistory = createClaudeHistoryWindow(); let cwd = Option.none(); @@ -1177,10 +1283,12 @@ export const make = Effect.gen(function* () { }; while (bytesRead < readBytes) { - const next = yield* file.readAlloc( + const next = yield* readChunk( Math.min(TRANSCRIPT_PREFIX_BYTES, readBytes - bytesRead), ); if (Option.isNone(next)) { + // A compressed rollout's decompressed length is only known at its end. + if (compressed && prefix === undefined) break; return null; } @@ -1398,7 +1506,12 @@ export const make = Effect.gen(function* () { } const directory = path.join(sessionsDir, year, month, day); for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { - if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if ( + !entry.startsWith("rollout-") || + !(entry.endsWith(".jsonl") || isCompressedTranscript(entry)) + ) { + continue; + } if (operationsRemaining <= 0) { truncated = true; break; @@ -1719,64 +1832,18 @@ export const make = Effect.gen(function* () { git: AgentSessionProjectGit | null; } >(); - // Resolve every candidate directory first so one batch reads all git identities. - const directories = new Map>(); - for (const candidate of raw) { - const expanded = expandHomePath(candidate.cwd.trim(), homeDir); - if (!path.isAbsolute(expanded)) continue; - const resolved = path.resolve(expanded); - if (directories.has(resolved)) continue; - directories.set(resolved, yield* projectDirectoryStats(resolved)); - } - const directoryGitIdentities = yield* readGitIdentities( - Array.from(directories).flatMap(([directory, stats]) => - Option.isSome(stats) ? [directory] : [], - ), + const directoryProjects = yield* resolveDirectoryProjects( + raw.flatMap((candidate) => { + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); + return path.isAbsolute(expanded) ? [path.resolve(expanded)] : []; + }), ); - const directoryProjects = new Map< - string, - { - readonly key: string; - readonly path: string; - readonly git: AgentSessionProjectGit | null; - } | null - >(); - for (const candidate of raw) { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; - const resolved = path.resolve(expanded); - const stats = directories.get(resolved); - if (stats === undefined || Option.isNone(stats)) continue; - let project = directoryProjects.get(resolved); - if (project === undefined) { - const gitIdentity = directoryGitIdentities.get(resolved); - if (gitIdentity === undefined) { - project = null; - } else if (gitIdentity._tag === "Worktree") { - // A linked worktree's sessions count toward its main checkout's project. - const { mainCheckout } = gitIdentity; - const mainStats = - mainCheckout === null ? Option.none() : yield* projectDirectoryStats(mainCheckout); - project = - mainCheckout !== null && Option.isSome(mainStats) - ? { - key: yield* directoryIdentity(mainCheckout, mainStats.value), - path: mainCheckout, - git: gitIdentity.git, - } - : null; - } else { - project = { - key: yield* directoryIdentity(resolved, stats.value), - path: resolved, - git: gitIdentity._tag === "Repository" ? gitIdentity.git : null, - }; - } - directoryProjects.set(resolved, project); - } - if (project === null) continue; + const project = directoryProjects.get(path.resolve(expanded)); + if (project === undefined || project === null) continue; const existing = merged.get(project.key); if (!existing) { @@ -1866,6 +1933,45 @@ export const make = Effect.gen(function* () { } const { candidates } = cachedCandidates; + // The scan counts a linked worktree's sessions toward its main checkout, so import them with it. + const candidateDirectories = candidates.flatMap((candidate) => { + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); + return path.isAbsolute(expanded) ? [path.resolve(expanded)] : []; + }); + const directoryProjects = yield* resolveDirectoryProjects(candidateDirectories); + const belongsToRoot = Effect.fn("AgentSessionScanner.belongsToRoot")(function* ( + resolved: string, + ) { + if ((yield* directoryIdentity(resolved)) === rootIdentity) return true; + if (!directoryProjects.has(resolved)) { + const project = (yield* resolveDirectoryProjects([resolved])).get(resolved) ?? null; + directoryProjects.set(resolved, project); + } + return directoryProjects.get(resolved)?.key === rootIdentity; + }); + const worktreesByDirectory = new Map(); + const rootWorktree = Effect.fn("AgentSessionScanner.rootWorktree")(function* ( + resolved: string, + ) { + const known = worktreesByDirectory.get(resolved); + if (known !== undefined) return known; + const gitDir = + (yield* directoryIdentity(resolved)) === rootIdentity + ? null + : (directoryProjects.get(resolved)?.worktreeGitDir ?? null); + let worktree: { path: string; branch: string | null } | null = null; + if (gitDir !== null) { + const headPath = path.join(gitDir, "HEAD"); + const head = (yield* readGitFiles([headPath])).get(headPath) ?? ""; + worktree = { + path: resolved, + branch: /^ref: refs\/heads\/(.+)$/.exec(head.trim())?.[1] ?? null, + }; + } + worktreesByDirectory.set(resolved, worktree); + return worktree; + }); + const eligibleTranscripts: Array<{ readonly candidate: RawCandidate; readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; @@ -1874,7 +1980,7 @@ export const make = Effect.gen(function* () { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); - if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + if (!(yield* belongsToRoot(resolved))) continue; for (const transcript of candidate.transcripts) { if ( @@ -2005,12 +2111,10 @@ export const make = Effect.gen(function* () { projectCwd(snapshot.cwd.value, transcript.projectRoots).trim(), homeDir, ); - if ( - !path.isAbsolute(expandedCwd) || - (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity - ) { + if (!path.isAbsolute(expandedCwd) || !(yield* belongsToRoot(path.resolve(expandedCwd)))) { return Option.some({ _tag: "Skipped" }); } + const worktree = yield* rootWorktree(path.resolve(expandedCwd)); const inheritedRecords = candidate.source === "codex" @@ -2024,7 +2128,12 @@ export const make = Effect.gen(function* () { { source: candidate.source, providerInstanceId: candidate.providerInstanceId, - fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + fallbackSessionId: path.basename( + transcript.filePath, + isCompressedTranscript(transcript.filePath) + ? COMPRESSED_TRANSCRIPT_SUFFIX + : ".jsonl", + ), lastActiveAtMs: transcript.mtimeMs, }, inheritedRecords === null @@ -2061,6 +2170,7 @@ export const make = Effect.gen(function* () { thread: savedTitle === undefined ? parsedThread : { ...parsedThread, title: savedTitle }, source, + worktree, }); }).pipe(importReadLock.withPermits(1)), ), diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 27316997077b..007f784374c1 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -66,6 +66,10 @@ export class ScratchProjectError extends Schema.TaggedError } } +/** Where No project threads keep their folders, whether or not this T3 home allows them. */ +export const scratchFolderRoot = (baseDir: string, path: Path.Path) => + path.resolve(baseDir, "scratch"); + export type ScratchError = ScratchUnavailableError | ScratchFolderError | ScratchProjectError; // The spawner checks cwd before spawning, so NotFound from spawn itself means a missing executable. @@ -255,7 +259,7 @@ const make = Effect.gen(function* () { Effect.flatMap( (repositoryRoot): Effect.Effect> => { if (repositoryRoot === null) { - return Effect.succeed(Result.succeed(path.resolve(config.baseDir, "scratch"))); + return Effect.succeed(Result.succeed(scratchFolderRoot(config.baseDir, path))); } const unavailable = new ScratchUnavailableError({ baseDir: config.baseDir, diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 9a2e36b43bc2..492379beecc2 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -31,6 +31,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; @@ -783,6 +784,69 @@ it.effect("resolves a restored icon immediately after reselecting its saved path }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect("refuses a worktree container as a new project and as a new workspace root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const processRunner = yield* ProcessRunner.ProcessRunner; + const fixture = yield* fileSystem.makeTempDirectoryScoped({ prefix: "project-container-" }); + const git = (cwd: string, ...args: ReadonlyArray) => + processRunner.run({ + command: "git", + args: ["-C", cwd, "-c", "user.email=t@t", "-c", "user.name=t", ...args], + }); + const source = `${fixture}/source`; + const container = `${fixture}/container`; + yield* fileSystem.makeDirectory(source); + yield* fileSystem.makeDirectory(container); + yield* git(source, "init", "-b", "develop"); + yield* fileSystem.writeFileString(`${source}/README.md`, "readme\n"); + yield* git(source, "add", "README.md"); + yield* git(source, "commit", "-m", "init"); + yield* git(fixture, "clone", "--bare", source, `${container}/.bare`); + yield* fileSystem.writeFileString(`${container}/.git`, "gitdir: ./.bare\n"); + yield* git(container, "worktree", "add", "develop", "develop"); + + yield* Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + // As cloned the container is bare; Initialize Git or a config edit clears core.bare. + for (const coreBare of ["true", "false"]) { + yield* git(container, "config", "core.bare", coreBare); + const rejected = yield* service + .create({ + commandId: CommandId.make(`command:container:create:${coreBare}`), + projectId: ProjectId.make(`project:container:${coreBare}`), + title: "Container", + workspaceRoot: container, + }) + .pipe(Effect.flip); + assert.equal(rejected._tag, "ProjectInvalidError", `core.bare=${coreBare}`); + assert.include(rejected.message, "Add one of its worktrees"); + assert.isTrue(Option.isNone(yield* service.getByWorkspaceRoot(container))); + } + + const projectId = ProjectId.make("project:container:worktree"); + const worktree = yield* service.create({ + commandId: CommandId.make("command:container:worktree"), + projectId, + title: "Worktree", + workspaceRoot: `${container}/develop`, + }); + const moved = yield* service + .update({ + commandId: CommandId.make("command:container:move"), + projectId, + workspaceRoot: container, + }) + .pipe(Effect.flip); + assert.equal(moved._tag, "ProjectInvalidError"); + assert.equal( + Option.getOrThrow(yield* service.getById(projectId)).workspaceRoot, + worktree.workspaceRoot, + ); + }).pipe(Effect.provide(layerTest)); + }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + it.effect("serializes two projects claiming the same workspace root", () => Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 464454ebba76..d6bc3e8eedf4 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -34,7 +34,9 @@ import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecuto import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; +import { makeIsWorktreeContainer } from "./worktreeContainer.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export interface ProjectCreateInput extends ProjectCreatePayload { @@ -182,6 +184,9 @@ export const make = Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; const fileSystem = yield* FileSystem.FileSystem; const isManagedWorktreePath = yield* makeIsManagedWorktreePath; + const isWorktreeContainer = yield* makeIsWorktreeContainer.pipe( + Effect.provide(ProcessRunner.layer), + ); const projectLocks = yield* KeyedLock.make(); const workspaceLocks = yield* KeyedLock.make(); @@ -244,6 +249,17 @@ export const make = Effect.gen(function* () { ), ); + const rejectWorktreeContainer = Effect.fn("ProjectService.rejectWorktreeContainer")(function* ( + projectId: ProjectId, + workspaceRoot: string, + ) { + if (!(yield* isWorktreeContainer(workspaceRoot))) return; + return yield* new ProjectInvalidError({ + projectId, + detail: `${workspaceRoot} holds a repository's worktrees but is not a checkout itself. Add one of its worktrees as the project instead.`, + }); + }); + const commit = Effect.fn("ProjectService.commit")(function* (command: ProjectCommand) { const { projectId } = command; const dispatchError = (cause: unknown) => @@ -383,6 +399,7 @@ export const make = Effect.gen(function* () { workspaceRoot: input.workspaceRoot, createIfMissing: input.createWorkspaceRootIfMissing ?? false, }); + yield* rejectWorktreeContainer(input.projectId, workspaceRoot); yield* commit({ type: "project.create", commandId: input.commandId, @@ -410,6 +427,9 @@ export const make = Effect.gen(function* () { projectId: input.projectId, workspaceRoot: input.workspaceRoot, }); + if (workspaceRoot !== previousRoot) { + yield* rejectWorktreeContainer(input.projectId, workspaceRoot); + } yield* commit({ type: "project.meta.update", commandId: input.commandId, diff --git a/apps/server/src/project/worktreeContainer.test.ts b/apps/server/src/project/worktreeContainer.test.ts new file mode 100644 index 000000000000..b10f8db93dab --- /dev/null +++ b/apps/server/src/project/worktreeContainer.test.ts @@ -0,0 +1,94 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ProcessRunner from "../processRunner.ts"; +import { makeIsWorktreeContainer } from "./worktreeContainer.ts"; + +const git = Effect.fn("worktreeContainerTest.git")(function* ( + cwd: string, + ...args: ReadonlyArray +) { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner.run({ + command: "git", + args: ["-C", cwd, "-c", "user.email=t@t", "-c", "user.name=t", ...args], + }); +}); + +it.layer(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))( + "isWorktreeContainer", + (it) => { + it.effect("is true only for a folder of worktrees around metadata that is no checkout", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const isWorktreeContainer = yield* makeIsWorktreeContainer; + const fixture = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-container-test-" }); + + const source = path.join(fixture, "source"); + yield* fileSystem.makeDirectory(source); + yield* git(source, "init", "-b", "develop"); + yield* fileSystem.writeFileString(path.join(source, "README.md"), "readme\n"); + yield* git(source, "add", "README.md"); + yield* git(source, "commit", "-m", "init"); + + const makeContainer = Effect.fn(function* (name: string, coreBare: boolean) { + const root = path.join(fixture, name); + yield* fileSystem.makeDirectory(root); + yield* git(fixture, "clone", "--bare", source, path.join(root, ".bare")); + yield* fileSystem.writeFileString(path.join(root, ".git"), "gitdir: ./.bare\n"); + yield* git(root, "worktree", "add", "develop", "develop"); + yield* git(root, "config", "core.bare", String(coreBare)); + return root; + }); + const makeSeparateGitDirCheckout = Effect.fn(function* (name: string) { + const root = path.join(fixture, name); + yield* fileSystem.makeDirectory(root); + yield* git(root, "init", "-b", "develop", "--separate-git-dir", ".metadata", "."); + return root; + }); + + const container = yield* makeContainer("container", false); + assert.isTrue(yield* isWorktreeContainer(container), "container"); + assert.isFalse(yield* isWorktreeContainer(path.join(container, "develop")), "its worktree"); + + const linked = path.join(fixture, "link"); + yield* fileSystem.symlink(container, linked); + assert.isTrue(yield* isWorktreeContainer(linked), "container through a symlink"); + + // The issue's steps leave core.bare set; Initialize Git would clear it later. + const bare = yield* makeContainer("bare", true); + assert.isTrue(yield* isWorktreeContainer(bare), "core.bare container"); + + // Git allows an orphan worktree before the first commit: no index, yet a valid checkout. + const fresh = yield* makeSeparateGitDirCheckout("fresh"); + yield* git(fresh, "worktree", "add", "--orphan", "-b", "feature", "../fresh-feature"); + assert.isFalse(yield* isWorktreeContainer(fresh), "fresh checkout with an orphan worktree"); + + const checkout = yield* makeSeparateGitDirCheckout("checkout"); + yield* fileSystem.writeFileString(path.join(checkout, "README.md"), "readme\n"); + yield* git(checkout, "add", "README.md"); + yield* git(checkout, "commit", "-m", "init"); + yield* git(checkout, "worktree", "add", "worktrees/feature", "-b", "feature"); + assert.isFalse(yield* isWorktreeContainer(checkout), "checkout with a nested worktree"); + + const outside = path.join(fixture, "outside"); + yield* git( + fixture, + "clone", + "--no-checkout", + "--separate-git-dir", + path.join(outside, ".metadata"), + source, + outside, + ); + yield* git(outside, "worktree", "add", "../outside-feature", "-b", "feature"); + assert.isFalse(yield* isWorktreeContainer(outside), "worktrees outside the folder"); + }), + ); + }, +); diff --git a/apps/server/src/project/worktreeContainer.ts b/apps/server/src/project/worktreeContainer.ts new file mode 100644 index 000000000000..f20e9c4375ca --- /dev/null +++ b/apps/server/src/project/worktreeContainer.ts @@ -0,0 +1,95 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import * as ProcessRunner from "../processRunner.ts"; +import { explicitBareRepositoryEnv, unsetInheritedRepositoryEnv } from "../vcs/VcsProcess.ts"; + +const parseGitfileTarget = (contents: string): string | undefined => { + const firstLine = contents.split(/\r?\n/u, 1)[0] ?? ""; + if (!firstLine.startsWith("gitdir:")) return undefined; + const target = firstLine.slice("gitdir:".length).trim(); + return target === "" ? undefined : target; +}; + +const splitNul = (stdout: string) => stdout.split("\0").filter((entry) => entry !== ""); + +/** + * Whether a folder only holds a repository's metadata and worktrees: its `.git` file points at + * metadata inside it, a linked worktree lives inside it, and either the repository is bare or it + * has commits but no index. A checkout whose tracked files are all deleted keeps its index, and a + * fresh one has no commit. Anything this cannot establish answers false. + */ +export const makeIsWorktreeContainer = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const processRunner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcess.Platform; + const env = { + ...unsetInheritedRepositoryEnv(process.env, platform), + ...explicitBareRepositoryEnv(process.env, platform), + }; + + const realPath = (target: string) => fileSystem.realPath(target).pipe(Effect.option); + const isInside = (parent: string, child: string) => { + const relative = path.relative(parent, child); + return ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); + }; + + return Effect.fn("ProjectService.isWorktreeContainer")(function* (workspaceRoot: string) { + // A `.git` directory is an ordinary checkout, so only a gitfile costs any Git process. + const gitfileTarget = yield* fileSystem.readFileString(path.join(workspaceRoot, ".git")).pipe( + Effect.map(parseGitfileTarget), + Effect.orElseSucceed(() => undefined), + ); + if (gitfileTarget === undefined) return false; + const root = yield* realPath(workspaceRoot); + const gitDir = yield* realPath(path.resolve(workspaceRoot, gitfileTarget)); + if (root._tag === "None" || gitDir._tag === "None") return false; + if (!isInside(root.value, gitDir.value)) return false; + + const git = (args: ReadonlyArray) => + processRunner + .run({ + command: "git", + args: ["-C", workspaceRoot, ...args], + env, + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.map((result) => (result.code === 0 && !result.timedOut ? result.stdout : null)), + Effect.orElseSucceed(() => null), + ); + + // Initializing Git in a bare container clears core.bare, so it is refused while still bare. + const isBare = yield* git(["rev-parse", "--is-bare-repository"]); + if (isBare === null) return false; + if (isBare.trim() !== "true") { + const hasCommit = yield* git(["rev-parse", "--quiet", "--verify", "HEAD^{commit}"]); + if (hasCommit === null) return false; + const hasIndex = yield* fileSystem + .exists(path.join(gitDir.value, "index")) + .pipe(Effect.orElseSucceed(() => true)); + if (hasIndex) return false; + } + + const worktrees = yield* git(["worktree", "list", "--porcelain", "-z"]); + if (worktrees === null) return false; + // The first entry is the main worktree, which Git reports as the metadata directory here. + const linkedWorktreePaths = splitNul(worktrees) + .filter((field) => field.startsWith("worktree ")) + .slice(1) + .map((field) => field.slice("worktree ".length)); + for (const worktreePath of linkedWorktreePaths) { + const worktree = yield* realPath(worktreePath); + if (worktree._tag === "Some" && isInside(root.value, worktree.value)) return true; + } + return false; + }); +}); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 7e380189ee6c..2a7d02fc2817 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -44,6 +44,7 @@ import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import { buildCodexAdditionalContext } from "./CodexDeveloperInstructions.ts"; import { DocumentText } from "./DocumentText.ts"; +import { EnvironmentThemeService } from "../environmentTheme.ts"; import { ProviderRegistry } from "./ProviderRegistry.ts"; import budget from "./agentContextBudget.json" with { type: "json" }; @@ -52,6 +53,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(AssetSigner)({}), Layer.mock(DeviceService)({}), Layer.mock(DocumentText)({}), + Layer.mock(EnvironmentThemeService)({}), Layer.mock(GitVcsDriver)({}), Layer.mock(GitWorkflowService)({}), Layer.mock(ManagedProjectFolders)({ namedProjectsRoot: "" }), @@ -116,13 +118,16 @@ const layerAdvertisedTools = McpHttpServer.layerTools.pipe( ); // Claude Code loads these tools up front instead of behind ToolSearch, outside this budget. -it.effect("only request_secret asks Claude Code to load it up front", () => +it.effect("only request_secret and send_to_user ask Claude Code to load them up front", () => Effect.gen(function* () { const mcp = yield* McpServer.McpServer; const alwaysLoad = mcp.tools .filter(({ tool }) => tool._meta?.["anthropic/alwaysLoad"] !== undefined) .map(({ tool }) => [tool.name, tool._meta]); - expect(alwaysLoad).toEqual([["request_secret", { "anthropic/alwaysLoad": true }]]); + expect(alwaysLoad).toEqual([ + ["request_secret", { "anthropic/alwaysLoad": true }], + ["send_to_user", { "anthropic/alwaysLoad": true }], + ]); }).pipe(Effect.provide(layerAdvertisedTools)), ); diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index bf3c9318877b..e2622bc9c2a6 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -14,10 +14,13 @@ import { buildClaudeCapabilitiesProbeQueryOptions, probeClaudeCapabilities, probeClaudeWorkspaceSnapshot, + spawnClaudeProbeThroughEnv, } from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); +const hostPath = process.env.PATH; + const decodeClaudeSettings = Schema.decodeEffect(ClaudeSettings); it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { @@ -27,6 +30,7 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp environment: { HOME: "/home/user" }, cwd: undefined, managedMcpConfig: true, + spawnThroughEnv: false, }); // Claude exits before initializing when handed --strict-mcp-config here. @@ -35,6 +39,29 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); +// A shell trampoline would not do: dash drops the exported function, and the launcher exits 127. +it.live.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "starts a Bash launcher that calls an exported function in the probe's workspace", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspace = yield* fileSystem.realPath(yield* fileSystem.makeTempDirectoryScoped()); + yield* fileSystem.writeFileString(`${workspace}/claude`, '#!/bin/bash\nt3_launch "$@"\n'); + yield* fileSystem.chmod(`${workspace}/claude`, 0o755); + const child = spawnClaudeProbeThroughEnv({ + command: "claude", + args: ["--probe"], + cwd: workspace, + env: { PATH: `.:${hostPath}`, "BASH_FUNC_t3_launch%%": '() { echo "$PWD $@"; }' }, + // @effect-diagnostics-next-line abortControllerInEffect:off - The launcher exits on its own. + signal: new AbortController().signal, + }); + assert.deepEqual(child.spawnargs.slice(0, 3), ["/usr/bin/env", "-C", workspace]); + const output = yield* Effect.promise(() => child.stdout.setEncoding("utf8").toArray()); + assert.equal(output.join(""), `${workspace} --probe\n`); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("reports a deleted worktree's scan as finished, not pending", () => Effect.gen(function* () { const snapshot = yield* probeClaudeWorkspaceSnapshot( diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index f969544fad06..0fb7ea8b2e1f 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK's spawn hook returns a Node child process. +import * as NodeChildProcess from "node:child_process"; + import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { type ClaudeSettings, @@ -18,6 +21,8 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { envTrampoline, supportsEnvChdir } from "@t3tools/shared/envTrampoline"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { query as claudeQuery, @@ -27,6 +32,7 @@ import { type SDKControlGetUsageResponse, type SDKUserMessage, type SettingSource, + type SpawnOptions as ClaudeSpawnOptions, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -227,14 +233,38 @@ const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ "local", ] as const satisfies ReadonlyArray; +/** + * The SDK's own spawn stats the executable and hands libuv the workspace as cwd, so a mount + * that stops answering freezes the server. `env` enters the cwd and finds the CLI in the child. + * A shell would not do: dash drops the exported Bash functions a launcher may call. + */ +export function spawnClaudeProbeThroughEnv({ + command, + args, + cwd, + env, + signal, +}: ClaudeSpawnOptions) { + const launch = envTrampoline(command, args, cwd) ?? { command, args, cwd }; + return NodeChildProcess.spawn(launch.command, launch.args, { + ...("cwd" in launch ? { cwd: launch.cwd } : {}), + env, + signal, + // The probe ignores stderr. + stdio: ["pipe", "pipe", "ignore"], + }); +} + export function buildClaudeCapabilitiesProbeQueryOptions(input: { readonly executablePath: string; readonly abortController: AbortController; readonly environment: NodeJS.ProcessEnv; readonly cwd: string | undefined; readonly managedMcpConfig: boolean; + readonly spawnThroughEnv: boolean; }): ClaudeQueryOptions { return { + ...(input.spawnThroughEnv ? { spawnClaudeCodeProcess: spawnClaudeProbeThroughEnv } : {}), persistSession: false, pathToClaudeCodeExecutable: input.executablePath, abortController: input.abortController, @@ -464,6 +494,8 @@ const probeClaudeCapabilities = ( ...(yield* readClaudeSettingsEnv(claudeSettings, cwd, claudeEnvironment)), }) : 0; + const spawnThroughEnv = + (yield* HostProcess.Platform) === "linux" && (yield* Effect.promise(supportsEnvChdir)); return yield* Effect.tryPromise({ try: async () => { const q = claudeQuery({ @@ -477,6 +509,7 @@ const probeClaudeCapabilities = ( environment: claudeEnvironment, cwd, managedMcpConfig, + spawnThroughEnv, }), }); const init = await q.initializationResult(); @@ -776,6 +809,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ?? resolvedEnvironment.ANTHROPIC_BASE_URL; const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined; + const endpoint = customApiEndpoint ? URL.parse(baseUrl?.trim() ?? "")?.origin : undefined; if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") { const path = yield* Path.Path; const configDir = (yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment)) @@ -804,7 +838,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( // A proxy's ANTHROPIC_AUTH_TOKEN reads as an API key; the endpoint is what the token is for. const authMetadata = apiProviderAuthMetadata(capabilities.apiProvider) ?? - (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined) ?? + (customApiEndpoint + ? { type: "customEndpoint", label: "Custom API endpoint", ...(endpoint ? { endpoint } : {}) } + : undefined) ?? claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 7eadf550eab2..ad15bf0896eb 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { applyPreferredCodexDefaultModel, + codexCustomEndpoint, codexRoutesSpendNativeQuota, mapCodexModelCapabilities, } from "./CodexProvider.ts"; @@ -192,4 +193,12 @@ it("reports native quota only for an OpenAI login on a native route", () => { name, ); } + // The custom endpoint is what a usage-limit hub is matched against. + assert.strictEqual(codexCustomEndpoint(hub), "https://hub.example.com"); + assert.strictEqual( + codexCustomEndpoint({ openai_base_url: "http://127.0.0.1:8317/v1" }), + "http://127.0.0.1:8317", + ); + assert.strictEqual(codexCustomEndpoint({ openai_base_url: "https://chatgpt.com/x" }), undefined); + assert.strictEqual(codexCustomEndpoint({}), undefined); }); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ab2cda61e53e..df105d3ba20f 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -57,7 +57,6 @@ import { import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); -const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; type CodexRateLimitsProbe = | { @@ -95,17 +94,21 @@ const CodexCyberAccessConfig = Schema.Struct({ }); const decodeCodexCyberAccessConfig = Schema.decodeUnknownOption(CodexCyberAccessConfig); -function routesToNativeOpenai(config: unknown): boolean { +// Null is Codex's auth-dependent OpenAI default; undefined is a route that cannot be read. +function codexRouteBaseUrl(config: unknown): string | null | undefined { const decoded = decodeCodexRoutingConfig(config); - if (Option.isNone(decoded)) return false; + if (Option.isNone(decoded)) return undefined; const routing = decoded.value; const providerId = routing.model_provider ?? "openai"; + if (providerId === "openai") return routing.openai_base_url || null; const provider = routing.model_providers?.[providerId]; - if (providerId !== "openai" && !provider) return false; - const baseUrl = - providerId === "openai" ? routing.openai_base_url || undefined : provider?.base_url; - // A provider without an override uses Codex's auth-dependent OpenAI default. - if (baseUrl == null) return true; + return provider ? (provider.base_url ?? null) : undefined; +} + +function routesToNativeOpenai(config: unknown): boolean { + const baseUrl = codexRouteBaseUrl(config); + if (baseUrl === undefined) return false; + if (baseUrl === null) return true; if (!URL.canParse(baseUrl)) return false; const url = new URL(baseUrl); return ( @@ -114,6 +117,15 @@ function routesToNativeOpenai(config: unknown): boolean { ); } +/** The origin of the custom endpoint Codex sends model requests to, if it uses one. */ +export function codexCustomEndpoint(config: unknown): string | undefined { + const baseUrl = codexRouteBaseUrl(config); + const url = baseUrl ? URL.parse(baseUrl) : null; + return url && /^https?:$/.test(url.protocol) && !routesToNativeOpenai(config) + ? url.origin + : undefined; +} + // A stored login does not mean this provider's traffic spends that login's quota. export const codexRoutesSpendNativeQuota = ( account: CodexSchema.V2GetAccountResponse, @@ -141,6 +153,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly endpoint?: string | undefined; readonly nativeUsage?: boolean; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; @@ -670,11 +683,7 @@ const readCodexRateLimits = ( client: CodexClient.CodexAppServerClient["Service"], deadlineMs: number, ) => - withCodexDiscoveryDeadline( - client.request("account/rateLimits/read", null), - deadlineMs, - RATE_LIMITS_PROBE_TIMEOUT_MS, - ).pipe( + withCodexDiscoveryDeadline(client.request("account/rateLimits/read", null), deadlineMs).pipe( Effect.map((response): CodexRateLimitsProbe => response ? { @@ -762,6 +771,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const cyberAccess = Option.getOrUndefined(decodeCodexCyberAccessConfig(config)); return { account: accountResponse, + endpoint: config ? codexCustomEndpoint(config) : undefined, nativeUsage, ...(rateLimits ? { rateLimits } : {}), version, @@ -1080,7 +1090,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu installed: true, version: snapshot.version ?? null, status: snapshot.models === undefined ? "warning" : accountStatus.status, - auth: accountStatus.auth, + auth: snapshot.endpoint + ? { ...accountStatus.auth, endpoint: snapshot.endpoint } + : accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : snapshot.models === undefined diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index 5060d8a24e5e..c81b38a00dbc 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -273,15 +273,43 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); - it.effect("rejects an oversized skill instead of returning an incomplete catalog", () => + it.effect("does not spend the scan budget on skill bodies", () => Effect.gen(function* () { const path = yield* Path.Path; const input = yield* makeWorkspace(); - const skillPath = yield* writeSkill( - path.join(input.cwd, ".agents", "skills", "oversized"), - `---\nname: oversized\ndescription: Read a large skill.\n---\n${"x".repeat(1_000_000)}`, + const root = path.join(input.userHome, ".gemini", "config", "skills"); + const expected = []; + for (let index = 0; index < 9; index += 1) { + const name = `large-${index}`; + const description = `Large skill ${index}. ${"y".repeat(2_000)}`; + const skillPath = yield* writeSkill( + path.join(root, name), + `---\nname: ${name}\ndescription: ${description}\n---\n${"x".repeat(950_000)}`, + ); + expected.push({ name, description, path: skillPath, scope: "user", enabled: true }); + } + + assert.deepEqual(yield* discoverAntigravitySkills(input), expected); + }), + ); + + it.effect("rejects frontmatter that does not close within the per-skill limit", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + const largeBodyPath = yield* writeSkill( + path.join(root, "large-body"), + `---\nname: large-body\n---\n${"x".repeat(2_000_000)}`, ); + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "large-body", path: largeBodyPath, scope: "project", enabled: true }, + ]); + const skillPath = yield* writeSkill( + path.join(root, "oversized"), + `---\nname: oversized\ndescription: ${"x".repeat(1_000_000)}\n---\n`, + ); const result = yield* discoverAntigravitySkills(input).pipe(Effect.result); assert.equal(result._tag, "Failure"); if (result._tag === "Failure") { @@ -291,7 +319,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); - it.effect("bounds the total read size across skills", () => + it.effect("bounds the total frontmatter read size across skills", () => Effect.gen(function* () { const path = yield* Path.Path; const input = yield* makeWorkspace(); @@ -299,7 +327,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { for (let index = 0; index < 9; index += 1) { yield* writeSkill( path.join(root, `large-${index}`), - `---\nname: large-${index}\n---\n${"x".repeat(900_000)}`, + `---\nname: large-${index}\ndescription: ${"x".repeat(900_000)}\n---\n`, ); } diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 19e9d8d293f4..96e1fe61109f 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -37,6 +37,8 @@ export function antigravityUserSkillDirectories( const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; +// Typical frontmatter fits in one chunk, so most skills cost about this much of the scan budget. +const SKILL_READ_CHUNK_BYTES = 1024; const SkillFrontmatter = Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), @@ -106,7 +108,29 @@ function skillPathSortKey(entry: string) { ); } -const readSkill = Effect.fn("readAntigravitySkill")(function* ( +const DASH = 0x2d; + +/** Finds the byte offset after the second `---`, as parseSkillFrontmatter's indexOf calls do. */ +function makeFrontmatterEndFinder() { + let bytesSeen = 0; + let dashes = 0; + let markers = 0; + return (chunk: Uint8Array) => { + for (const byte of chunk) { + bytesSeen += 1; + dashes = byte === DASH ? dashes + 1 : 0; + if (dashes === 3) { + dashes = 0; + markers += 1; + if (markers === 2) return bytesSeen; + } + } + return undefined; + }; +} + +/** Reads through the frontmatter only, so skill bodies do not spend the scan budget. */ +const readSkillFrontmatter = Effect.fn("readAntigravitySkill")(function* ( skillPath: string, budget: ScanBudget, ) { @@ -115,26 +139,27 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( if (info?.type !== "File") return undefined; const byteLimit = Math.min(MAX_SKILL_BYTES, budget.remainingBytes); - if (info.size > BigInt(byteLimit)) { - return yield* new AntigravitySkillsProbeError({ - reason: "scan-budget-exhausted", - path: skillPath, - }); - } + const findFrontmatterEnd = makeFrontmatterEndFinder(); + let frontmatterEnd: number | undefined; const chunks = yield* readIfPresent( - fileSystem.stream(skillPath, { bytesToRead: byteLimit + 1 }).pipe(Stream.runCollect), + fileSystem + .stream(skillPath, { bytesToRead: byteLimit + 1, chunkSize: SKILL_READ_CHUNK_BYTES }) + .pipe( + Stream.takeUntil((chunk) => (frontmatterEnd = findFrontmatterEnd(chunk)) !== undefined), + Stream.runCollect, + ), skillPath, ); if (chunks === undefined) return undefined; const bytes = Buffer.concat(chunks); - if (bytes.byteLength > byteLimit) { + budget.remainingBytes -= bytes.byteLength; + if ((frontmatterEnd ?? bytes.byteLength) > byteLimit) { return yield* new AntigravitySkillsProbeError({ reason: "scan-budget-exhausted", path: skillPath, }); } - budget.remainingBytes -= bytes.byteLength; - return bytes.toString("utf8"); + return bytes.toString("utf8", 0, frontmatterEnd); }); /** Keep read errors typed so workspace snapshots cannot cache partial results. */ @@ -187,7 +212,7 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( if (skillFileName !== undefined) { if (!skillFileName.endsWith(".md")) return; const skillPath = path.join(directory, skillFileName); - const contents = yield* readSkill(skillPath, budget); + const contents = yield* readSkillFrontmatter(skillPath, budget); if (contents === undefined) return; const skill = parseSkillFrontmatter(contents, skillFileName); if (!skill || skillsByName.has(skill.name)) return; diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts index 21d1d4651be3..70b689da7a75 100644 --- a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts @@ -383,6 +383,7 @@ it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { }).pipe( Effect.provide( Layer.mock(ProviderHost.ProviderHost)({ + awaitServerReady: Effect.void, paths: { cwd: f.directory, baseDir: f.directory, diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 6ce141eec733..bdf2ed1cae61 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -22,12 +22,14 @@ export interface ClaudeSkillDispatch { export function planClaudeSkillDispatch( prompt: string, skills: ReadonlyMap, + /** Mentions before this offset, such as inline handoff history, stay plain text. */ + mentionsFrom = 0, ): ClaudeSkillDispatch | undefined { const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { const name = match[2] ?? ""; const skill = skills.get(name); - if (!skill) return []; const start = (match.index ?? 0) + (match[1]?.length ?? 0); + if (!skill || start < mentionsFrom) return []; return [{ name, skill, start, end: (match.index ?? 0) + match[0].length }]; }); const last = mentions.at(-1); diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index cacfc6c06211..1cdbe05d0766 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -589,6 +589,98 @@ describe("parseCodexLine", () => { }); }); + const at = (time: string, line: string) => line.replace("05:17:49.919Z", `${time}.000Z`); + const anonymousRequest = (time: string, usage: unknown) => + JSON.stringify({ + type: "token_usage_record", + timestamp: `2026-08-01T${time}.000Z`, + payload: { usage }, + }); + it.each([ + { + name: "does not bill an older snapshot re-emitted after requests without cumulative counters", + lines: [ + at("10:00:00", counted(counters(100, 20), counters(100, 20))), + at("10:00:05", counted(counters(20, 4))), + at("10:00:10", counted(counters(30, 6))), + at("10:00:05", counted(counters(20, 4), counters(120, 24))), + ], + processed: [120, 24, 36, 0], + skipped: 1, + }, + { + name: "bills a rewind that follows a request without cumulative counters", + lines: [ + at("10:00:00", counted(counters(100, 20))), + at("10:00:10", counted(counters(20, 4), counters(60, 12))), + ], + processed: [120, 24], + skipped: 0, + }, + { + name: "keeps the baseline when it rejects an older snapshot, so the replay that follows is not billed", + lines: [ + at("10:00:00", counted(counters(100, 20), counters(100, 20))), + at("10:00:05", counted(counters(20, 4), counters(120, 24))), + at("10:00:10", counted(counters(30, 6), counters(150, 30))), + at("10:00:05", counted(counters(20, 4), counters(120, 24))), + at("10:00:10", counted(counters(30, 6), counters(150, 30))), + ], + processed: [120, 24, 36, 0, 0], + skipped: 1, + }, + { + name: "keeps the baseline when it rejects a lower snapshot from the baseline's own time", + lines: [ + at("10:00:00", counted(counters(100, 20), counters(100, 20))), + at("10:00:00", counted(counters(20, 4), counters(120, 24))), + at("10:00:00", counted(counters(30, 6), counters(150, 30))), + at("10:00:00", counted(counters(20, 4), counters(120, 24))), + at("10:00:00", counted(counters(30, 6), counters(150, 30))), + ], + processed: [120, 24, 36, 0, 0], + skipped: 1, + }, + { + name: "does not bill replayed snapshots against a cached baseline of unknown age", + lines: [ + at("10:00:00", counted(counters(100, 20), counters(100, 20))), + at("10:00:05", counted(counters(20, 4), counters(120, 24))), + at("10:00:10", counted(counters(30, 6), counters(150, 30))), + at("10:00:00", counted(counters(100, 20), counters(100, 20))), + at("10:00:05", counted(counters(20, 4), counters(120, 24))), + ], + undatedAfter: 3, + processed: [120, 24, 36, 0, 0], + skipped: 1, + }, + { + name: "warns about the anonymous request a rewound snapshot does not report", + lines: [ + at("10:00:00", counted(counters(100, 20), counters(100, 20))), + anonymousRequest("10:00:05", counters(40, 8)), + anonymousRequest("10:00:06", counters(20, 4)), + at("10:00:07", counted(counters(20, 4), counters(60, 12))), + at("10:00:08", counted(counters(10, 2), counters(70, 14))), + ], + processed: [120, 0, 0, 24, 12], + skipped: 1, + }, + ])("$name", ({ lines, undatedAfter, processed, skipped }) => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const billed = lines.map((line, index) => { + if (index === undatedAfter) delete state.latestCumulativeAtMs; + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + + expect({ processed: billed, skipped: codexUsageFormat.readMalformedRecords?.(state) }).toEqual({ + processed, + skipped, + }); + }); + it("ignores a replay of the current sequence's first request without rewinding the baseline", () => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index 7f99d611c3de..a2977d3080fe 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -54,10 +54,15 @@ function readCodexTokenUsage(value: unknown): CodexTokenUsage | null { return isCodexTokenUsage(usage) ? usage : null; } +// Every move of the baseline dates it, so an older snapshot re-emitted later is never a rewind. +function dateCodexBaseline(state: CodexScanState, timestampMs: number) { + state.latestCumulativeAtMs = Math.max(state.latestCumulativeAtMs ?? timestampMs, timestampMs); +} + // A zeroed baseline rebases the counters, so the sequence start measured against it is forgotten. function rebaseCodexUsage(state: CodexScanState, cumulative: CodexTokenUsage, timestampMs: number) { state.lastCumulativeUsage = cumulative; - state.latestCumulativeAtMs = Math.max(state.latestCumulativeAtMs ?? timestampMs, timestampMs); + dateCodexBaseline(state, timestampMs); delete state.unreportedRequests; if (cumulative.input_tokens + cumulative.output_tokens === 0) delete state.sequenceStartSignature; } @@ -112,6 +117,18 @@ function unreportedRequests( ); } +// A rewound snapshot vouches only for the request it reports, so the anonymous requests read before +// it can be neither billed nor ruled out as that request's prefix. +function discardedRequests( + pending: readonly CodexUnreportedRequest[] | undefined, + last: CodexTokenUsage, +) { + const final = pending?.at(-1); + if (pending === undefined || final === undefined) return 0; + const reported = CODEX_USAGE_FIELDS.every((key) => final.usage[key] === last[key]); + return pending.length - (reported ? 1 : 0); +} + /** * Codex `token_count` events carry no model or service tier, so both are carried forward: the model * from the most recent `turn_context`, the tier from the most recent `thread_settings_applied`. @@ -251,6 +268,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, request.usage); } state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, usage); + dateCodexBaseline(state, timestampMs); delete state.unreportedRequests; return [ ...pending.map((request) => codexRequestRecord(state, request.usage, request)), @@ -295,6 +313,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag if (cumulative !== null) rebaseCodexUsage(state, cumulative, timestampMs); else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) { state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last); + dateCodexBaseline(state, timestampMs); } state.lastUsageSignature = signature; return []; @@ -345,15 +364,18 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag // Only the same event is a replay; a later request can restart with identical counters. if (restarted && sequenceStart === state.sequenceStartSignature) { state.lastCumulativeUsage = previous; + // The retained baseline keeps its own age; one of unknown age must not take the replay's. + if (latestCumulativeAtMs === undefined) delete state.latestCumulativeAtMs; return []; } // Forks and paginated continuations can inherit totals without copying a counter snapshot, and // a later request can continue from an earlier snapshot; a re-emitted old snapshot is not later. - // Only bill the latest request, and require the unseen prefix to be valid usage too. + // Only bill the latest request, and require the unseen prefix to be valid usage too. A baseline + // of unknown age (a scan state cached before every move dated it) cannot order the snapshot. + const lowered = + previous !== null && CODEX_USAGE_FIELDS.some((key) => cumulative[key] < previous[key]); const rewound = - previous !== null && - (latestCumulativeAtMs === undefined || timestampMs > latestCumulativeAtMs) && - CODEX_USAGE_FIELDS.some((key) => cumulative[key] < previous[key]); + lowered && latestCumulativeAtMs !== undefined && timestampMs > latestCumulativeAtMs; const reconciled = previous === null || rewound ? readCodexTokenUsage( @@ -363,10 +385,24 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag if (!restarted && !reconciled) { const bridged = unreportedRequests(previous, cumulative, last, pending); if (bridged === null) { + // A rejected snapshot from before the baseline's time is a replay, not the next baseline. + // At the baseline's own time only lower counters are one; zeroed counters still rebase. + const replayed = + latestCumulativeAtMs !== undefined && + (timestampMs < latestCumulativeAtMs || + (timestampMs === latestCumulativeAtMs && + lowered && + cumulative.input_tokens + cumulative.output_tokens > 0)); + if (replayed) { + state.lastCumulativeUsage = previous; + if (pending !== undefined) state.unreportedRequests = pending; + } state.malformedRecords += 1; return []; } unreported = bridged; + } else if (!restarted && rewound) { + state.malformedRecords += discardedRequests(pending, last); } if (restarted) state.sequenceStartSignature = sequenceStart; } else { @@ -378,6 +414,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag return []; } state.lastCumulativeUsage = advanced; + dateCodexBaseline(state, timestampMs); } state.lastUsageSignature = signature; diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 6398210599b6..fed07efc035e 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -4,11 +4,13 @@ import * as Digest from "effect/crypto/Digest"; 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 { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as DocumentText from "./DocumentText.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; @@ -22,6 +24,10 @@ export const layer = Layer.effect( const secrets = yield* ServerSecretStore.ServerSecretStore; const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; + // Optional so standalone provider layers in tests probe at once. + const lifecycleEvents = yield* Effect.serviceOption( + ServerLifecycleEvents.ServerLifecycleEvents, + ); return ProviderHost.ProviderHost.of({ paths: { cwd: config.cwd, @@ -37,6 +43,10 @@ export const layer = Layer.effect( subscribe: serverSettings.subscribeChanges, }, shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, + ...Option.match(lifecycleEvents, { + onNone: () => ({}), + onSome: (events) => ({ awaitServerReady: events.awaitReady }), + }), resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), // DocumentText owns the path-resolution span. diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 0601714dd618..564a90f40e6c 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -17,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -788,6 +789,86 @@ it.layer( }), ); + it.effect("shows Codex usage that answers after three seconds inside the probe budget", () => + Effect.gen(function* () { + const usageRequested = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Effect.scope; + const output = yield* Queue.unbounded(); + const send = (message: unknown) => + Queue.offer(output, encoder.encode(`${JSON.stringify(message)}\n`)); + const results: Record = { + initialize: { + userAgent: "codex_cli_rs/0.167.0", + codexHome: "/nonexistent-codex-home", + platformFamily: "unix", + platformOs: "linux", + }, + "account/read": { + account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + "config/read": { config: {}, origins: {} }, + "skills/list": { data: [] }, + "model/list": { data: [] }, + }; + const answer = (line: string) => { + const { id, method } = JSON.parse(line) as { id?: number; method?: string }; + if (id === undefined || method === undefined) return Effect.void; + if (method !== "account/rateLimits/read") { + return send({ id, result: results[method] ?? {} }); + } + return Deferred.succeed(usageRequested, undefined).pipe( + Effect.andThen(Effect.sleep("4 seconds")), + Effect.andThen( + send({ + id, + result: { + rateLimits: { + limitId: "codex", + planType: "pro", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 2e9 }, + }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + accountId: null, + }, + }), + ), + Effect.forkIn(scope), + ); + }; + const decoder = new TextDecoder(); + let buffered = ""; + return ChildProcessSpawner.makeHandle({ + ...mockHandle({ stdout: "", stderr: "", code: 0 }), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + stdin: Sink.forEach((chunk: Uint8Array) => { + buffered += decoder.decode(chunk, { stream: true }); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + return Effect.forEach(lines.filter(Boolean), answer, { discard: true }); + }), + stdout: Stream.fromQueue(output), + }); + }), + ); + const fiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* Deferred.await(usageRequested); + yield* TestClock.adjust("25 seconds"); + const status = yield* Fiber.join(fiber); + + assert.strictEqual(status.auth.status, "authenticated"); + assert.isUndefined(status.usageLimits?.unavailable); + assert.strictEqual(status.usageLimits?.windows[0]?.usedPercent, 42); + }), + ); + it.effect("does not run --version when the Codex executable cannot start", () => Effect.gen(function* () { if ((yield* HostProcess.Platform) === "win32") return; diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 486c4314927a..b229db200ab3 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5597, - "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48131 + "claudeSystemAppendBytes": 5930, + "codexAdditionalContextBytes": 7324, + "mcpToolListJsonBytes": 49838 } diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index f20d8b054363..b683c80f4f5e 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -199,6 +199,22 @@ describe("claudeUsageResponseToLimits", () => { ).toEqual([monthlyWindow]); }); + it("flags a $0 cap instead of reporting a monthly window", () => { + const response = { + rate_limits_available: true, + rate_limits: { + five_hour: null, + seven_day: null, + extra_usage: { is_enabled: true, used_credits: 0, monthly_limit: 0, utilization: null }, + }, + }; + expect(claudeUsageResponseToLimits({ checkedAt, response }).limits).toEqual({ + checkedAt, + windows: [], + zeroSpendCap: true, + }); + }); + it("omits disabled budgets and refuses invalid money without an enabled fallback", () => { const rateLimits = enterpriseResponse.rate_limits; const response = { diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index d0acf43bb496..ba1263ef704b 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -47,8 +47,20 @@ export const makeClaudeScopedLimitNames = Ref.make({ overageIncluded: undefined, }); +const SCOPED_WINDOW_PREFIX = "seven_day_"; +const scopeToken = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "_"); + function scopedWindowId(displayName: string): string { - return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; + return `${SCOPED_WINDOW_PREFIX}${scopeToken(displayName)}`; +} + +/** A model-scoped weekly allowance blocks only its own model; an unknown model is not blocked. */ +export function windowLimitsModel( + window: Pick, + model: string | null, +): boolean { + if (!window.id.startsWith(SCOPED_WINDOW_PREFIX)) return true; + return model !== null && scopeToken(model).includes(window.id.slice(SCOPED_WINDOW_PREFIX.length)); } function scopedWindow( @@ -202,6 +214,7 @@ export function claudeUsageResponseToLimits(input: { overageIncluded ??= entry.display_name; } const spendPercent = readSpendPercent(response.rate_limits); + const extra = response.rate_limits.extra_usage; if (spendPercent !== undefined) { windows.push({ id: "monthly_spend", @@ -211,7 +224,10 @@ export function claudeUsageResponseToLimits(input: { }); } return { - limits: makeUsageLimits({ checkedAt, windows }), + limits: { + ...makeUsageLimits({ checkedAt, windows }), + ...(extra?.is_enabled && extra.monthly_limit === 0 ? { zeroSpendCap: true } : {}), + }, names: { overageIncluded }, }; } diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 773d0c6b6ade..ad3a29ac398c 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3568,19 +3568,31 @@ it.effect("deduplicates routed PR reports without dropping draft changes", () => }), ); -it.effect( - "reuses fresh detail checks without extending their refresh interval on detail cache hits", - () => +it.effect.each(["github", "azure-devops"])( + "reuses fresh %s detail checks without extending their refresh interval on detail cache hits", + (provider) => Effect.gen(function* () { let reads = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const repository = provider === "azure-devops" ? "web" : "acme/web"; + const reference = { projectId: "p1" as ProjectId, repository, number: 1 }; const service = yield* makeService({ projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository, + provider, + ...(provider === "azure-devops" ? { host: "dev.azure.com" } : {}), + }), ], providers: [ - fakePullRequestProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), + fakePullRequestProvider(SourceControlProviderKind.make(provider), { + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("Description"), + checks: [{ name: "build", status: "success", description: null, url: null }], + }), getChangeRequestChecks: () => Effect.sync(() => { reads += 1; @@ -3610,6 +3622,37 @@ it.effect( }), ); +it.effect("asks Azure for checks when its detail came back with none", () => + Effect.gen(function* () { + let reads = 0; + const reference = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "web", + provider: "azure-devops", + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), + getChangeRequestChecks: () => + Effect.sync(() => { + reads += 1; + return { state: "open" as const, checks: [] }; + }), + }), + ], + }); + yield* service.detail(reference); + yield* service.checks(reference); + expect(reads).toBe(1); + }), +); + it.effect("preserves check observation time on cache hits and advances it on refresh", () => Effect.gen(function* () { let reads = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 2f79c4b524ee..2b8859304c5b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -3256,9 +3256,11 @@ export const make = Effect.gen(function* () { return detailUncached(refOfCacheKey(key)).pipe( Effect.tap( Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { - // GitHub detail includes the same checks; the checks TTL counts from their observation. + // GitHub and Azure detail include the same checks; the checks TTL counts from their observation. + // Azure detail answers a failed policy read with no checks, which must not be cached. if ( - value.provider === "github" && + (value.provider === "github" || + (value.provider === "azure-devops" && value.checks.length > 0)) && value.observedAt !== undefined && !(yield* Cache.has(checksCache, key)) ) { diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 93dc8b2371c2..303944ab48b6 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -239,6 +239,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( getThreadHistoryPage: () => Effect.die("unused"), getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), + getMessagesById: () => Effect.die("unused messages by id"), getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), getThreadProjection: unused, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f4d7fd2aedb9..651ef431350e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -117,6 +117,7 @@ function shouldPublishAgentAwarenessEvent( case "thread.active-reordered": case "thread.tags-updated": case "thread.visited": + case "thread.visit-recorded": case "thread.marked-unread": case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 7d9de87ecfd9..ca77c9e26220 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -187,6 +187,8 @@ export class DesktopTelemetryReceiver extends Context.Service< readonly setDiagnosticsDemand: ( enabled: boolean, ) => Effect.Effect; + /** Tells the desktop parent that the server accepts requests, so it can skip readiness polling. */ + readonly announceServerReady: Effect.Effect; readonly requestDesktopUpdate: ( requestId: string, trackSwitch?: DesktopTrackSwitch, @@ -717,6 +719,9 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") health: Ref.get(health), subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, + announceServerReady: sendControlMessage({ version: 1, type: "serverReady" }).pipe( + Effect.withSpan("DesktopTelemetryReceiver.announceServerReady"), + ), requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")( function (requestId, trackSwitch) { return sendControlMessage({ @@ -787,6 +792,7 @@ export const layerTest = ( setDiagnosticsDemand: Effect.fn("DesktopTelemetryReceiver.setDiagnosticsDemand")(function () { return Effect.void; }), + announceServerReady: Effect.void, requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")(function () { return Effect.void; }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f5aae8eb3716..27bcac39c8d7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -29,6 +29,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; +import * as NonBlockingSpawner from "./process/NonBlockingSpawner.ts"; import * as KeepAwake from "./background/KeepAwake.ts"; import * as ServerConfig from "./config.ts"; import { withUntracedRequests } from "./http.ts"; @@ -175,6 +176,7 @@ import * as ConnectReachability from "./cloud/ConnectReachability.ts"; import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; import * as RelayTracing from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; +import * as RelayClientLedger from "./cloud/RelayClientLedger.ts"; import { MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER, makeManagedTunnelRecovery, @@ -382,9 +384,14 @@ const layerHttpServer = Layer.unwrap( }, ); }), -).pipe(Layer.provide(ManagedPolicy.layer)); +).pipe( + Layer.provide(ManagedPolicy.layer), + // NodeHttpServer.layer also outputs NodeServices, whose spawner would shadow the wrapped one. + (layer) => NonBlockingSpawner.layer.pipe(Layer.provideMerge(layer)), +); const layerPlatformServices = DocumentText.layer.pipe( + Layer.provideMerge(NonBlockingSpawner.layer), Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); @@ -547,6 +554,14 @@ const layerCloudManagedEndpointRuntime = Layer.mergeAll( CloudManagedEndpointRuntime.layer.pipe( Layer.provide(ServerSecretStore.layer), Layer.provide(layerRelayClient), + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return RelayClientLedger.layer({ stateDir: config.stateDir }); + }), + ), + ), ), ); @@ -574,6 +589,7 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( ); const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( + Layer.provide(UsageLimitSources.layerResetFallback), Layer.provide(layerScheduledTaskWebhookOrigin), Layer.provide(ProviderUsageAnalytics.layer), Layer.provide(layerCheckpointStore), @@ -700,15 +716,15 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(SecretCheck.layer), Layer.provideMerge(SecretResolver.layer), + // Above the settings it writes when a theme is applied. + Layer.provideMerge(EnvironmentTheme.layer), Layer.provideMerge(layerServerSettings), Layer.provideMerge(layerSourceControlProviderRegistry), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), Layer.provideMerge(layerPersistence), - Layer.provideMerge( - Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), - ), + Layer.provideMerge(Layer.mergeAll(Keybindings.layer, UsageLimitSources.layer)), Layer.provideMerge(ProviderRegistry.layer), Layer.provideMerge( ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(SqlitePersistence.layerConfig)), @@ -895,6 +911,17 @@ const layerMakeServer = Layer.unwrap( yield* startup.markHttpListening; }), ); + const layerDesktopReadySignal = Layer.effectDiscard( + Effect.gen(function* () { + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; + const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; + yield* startup.awaitCommandReady.pipe( + Effect.andThen(receiver.announceServerReady), + Effect.ignore, + Effect.forkScoped, + ); + }), + ); const layerRuntimeState = Layer.effectDiscard( Effect.gen(function* () { yield* Deferred.succeed(runtimeStateParked, undefined).pipe(Effect.orDie); @@ -1275,6 +1302,7 @@ const layerMakeServer = Layer.unwrap( const layerServerApplication = Layer.mergeAll( layerRoutes, layerHttpListening, + layerDesktopReadySignal, layerRuntimeState.pipe(Layer.provide(layerLauncher)), layerTailscaleServe, layerCloudDesiredLinkReconcile, @@ -1304,4 +1332,7 @@ const layerMakeServer = Layer.unwrap( }), ).pipe(Layer.provide(ProcessRunner.layer)); -export const runServer = Layer.launch(layerMakeServer); +export const runServer = Layer.launch( + // For the layers above whose spawner requirement is met by the caller's runtime. + layerMakeServer.pipe(Layer.provide(NonBlockingSpawner.layer)), +); diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 7d394e4dc913..d90b7fa57bc3 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -1,5 +1,6 @@ import type { ServerLifecycleStreamEvent } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; @@ -22,11 +23,14 @@ export class ServerLifecycleEvents extends Context.Service< readonly publish: (event: LifecycleEventInput) => Effect.Effect; readonly snapshot: Effect.Effect; readonly stream: Stream.Stream; + /** Completes once the first ready event is published. */ + readonly awaitReady: Effect.Effect; } >()("t3/serverLifecycleEvents") {} const make = Effect.gen(function* () { const pubsub = yield* PubSub.unbounded(); + const ready = yield* Deferred.make(); const state = yield* Ref.make({ sequence: 0, events: [], @@ -45,8 +49,14 @@ const make = Effect.gen(function* () { ...current.events.filter((entry) => entry.type !== nextEvent.type), ]; return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; - }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), + }).pipe( + Effect.tap((event) => PubSub.publish(pubsub, event)), + Effect.tap((event) => + event.type === "ready" ? Deferred.succeed(ready, undefined) : Effect.void, + ), + ), snapshot: Ref.get(state), + awaitReady: Deferred.await(ready), get stream() { return Stream.fromPubSub(pubsub); }, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 7a3c6068a054..2b8d9874a8fe 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -52,6 +52,7 @@ it.effect.each([ "disabled-default", "launch-failure", "project-failure", + "project-refused", ] as const)("keeps bootstrap launch failures separate from project failures: %s", (scenario) => Effect.gen(function* () { const projectId = ProjectId.make("bootstrap-project"); @@ -155,22 +156,28 @@ it.effect.each([ bootstrap: () => scenario === "project-failure" ? Effect.die("project bootstrap failed") - : Effect.succeed({ - created: true, - project: { - id: projectId, - title: "Project", - workspaceRoot: "/bootstrap-project", - repositoryIdentity: null, - faviconPath: null, - scripts: [], - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - deletedAt: null, - defaultModelSelection: - scenario === "disabled-default" ? { instanceId, model: "custom-model" } : null, - }, - }), + : scenario === "project-refused" + ? Effect.fail( + new ProjectService.ProjectInvalidError({ projectId, detail: "refused folder" }), + ) + : Effect.succeed({ + created: true, + project: { + id: projectId, + title: "Project", + workspaceRoot: "/bootstrap-project", + repositoryIdentity: null, + faviconPath: null, + scripts: [], + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + deletedAt: null, + defaultModelSelection: + scenario === "disabled-default" + ? { instanceId, model: "custom-model" } + : null, + }, + }), }), Layer.mock(ThreadManagement.ThreadManagementService)({ countThreads: () => Effect.succeed(0), @@ -223,7 +230,10 @@ it.effect.each([ } else { assert.isDefined(welcome); if (welcome?.type === "welcome") { - assert.equal(welcome.payload.bootstrapProjectId, projectId); + assert.equal( + welcome.payload.bootstrapProjectId, + scenario === "project-refused" ? undefined : projectId, + ); assert.isUndefined(welcome.payload.bootstrapThreadId); } } diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 689659ab23f5..0d84e99169e1 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -270,12 +270,24 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { if (serverConfig.autoBootstrapProjectFromCwd) { const threadModelSelection = getAutoBootstrapThreadModelSelection(); - const { project } = yield* projects.bootstrap({ - commandId: CommandId.make(yield* randomUUID), - projectId: ProjectId.make(yield* randomUUID), - title: path.basename(serverConfig.cwd) || "project", - workspaceRoot: serverConfig.cwd, - }); + const bootstrapped = yield* projects + .bootstrap({ + commandId: CommandId.make(yield* randomUUID), + projectId: ProjectId.make(yield* randomUUID), + title: path.basename(serverConfig.cwd) || "project", + workspaceRoot: serverConfig.cwd, + }) + .pipe( + // A folder the project rules refuse must not stop the server from starting there. + Effect.catchTags({ + ProjectInvalidError: (error) => + Effect.logWarning("Skipped bootstrap project", { detail: error.detail }).pipe( + Effect.as(undefined), + ), + }), + ); + if (bootstrapped === undefined) return {} satisfies AutoBootstrapWelcomeTargets; + const { project } = bootstrapped; const existingThread = (yield* threads.listActiveShellIndex()).find( (thread) => thread.projectId === project.id && isListedThreadLineage(thread.lineage), ); diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index c7d6c5e5d8f3..ac699b9c7d6c 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -1586,6 +1586,56 @@ process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: paths.dat }), ); + it.effect("starts no trial when stopped during the pre-update backup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-backup-stop-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const childSource = "setInterval(() => {}, 1000);\n"; + for (const version of ["1.0.0", "1.1.0"]) { + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); + } + const dbPath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + yield* fs.writeFileString(dbPath, "database"); + const pending = { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + update: { + id: "update-1", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + dbPath, + status: "pending", + }, + } as const; + yield* Effect.promise(() => writeServiceState(statePath, pending)); + const childCount = spawnedChildren.length; + const launcher = new Launcher(root, pending); + + // Recovery is already queued, so this stop lands while the backup runs. + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + + // A trial started here arms its 30-minute prepared timer, which keeps the stopped launcher alive. + assert.deepEqual( + spawnedChildren.slice(childCount).map((child) => child.spawnargs.join(" ")), + [], + ); + assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), pending); + const backupDir = path.join(root, "runtime", "db-backup", "update-1"); + assert.isFalse(yield* fs.exists(backupDir)); + assert.isFalse(yield* fs.exists(`${backupDir}.staging`)); + }), + ); + it.effect("records no update when the server exits during the backup check", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index c07697007fe5..1a6c07cf4365 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -326,14 +326,18 @@ async function syncDirectory(directory: string): Promise { } } -function runCp(args: ReadonlyArray): Promise { +function runCp(args: ReadonlyArray, signal?: AbortSignal): Promise { return new Promise((resolve) => { - NodeChildProcess.execFile("cp", args, (error) => resolve(error === null)); + NodeChildProcess.execFile("cp", args, { signal }, (error) => resolve(error === null)); }); } // macOS fs.copyFile clone flags fall back to full copies. -async function copyDatabaseFile(source: string, destination: string): Promise { +async function copyDatabaseFile( + source: string, + destination: string, + signal?: AbortSignal, +): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; // cp -c replaces files and breaks hard links; copy into hard-linked destinations instead. @@ -346,7 +350,9 @@ async function copyDatabaseFile(source: string, destination: string): Promise { +async function backupDatabaseOnce( + baseDir: string, + pending: PendingServiceUpdate, + signal: AbortSignal, +): Promise { const backupDir = databaseBackupDir(baseDir, pending.id); if (await pathExists(backupDir)) return; @@ -534,10 +544,11 @@ async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); try { for (const suffix of DB_FILE_SUFFIXES) { + signal.throwIfAborted(); const source = `${pending.dbPath}${suffix}`; if (suffix !== "" && !(await pathExists(source))) continue; const destination = databaseBackupFile(stagingDir, suffix); - await copyDatabaseFile(source, destination); + await copyDatabaseFile(source, destination, signal); await syncFile(destination); } await NodeFSP.rename(stagingDir, backupDir); @@ -830,6 +841,7 @@ export class Launcher { #timer: NodeJS.Timeout | undefined; #transitions: Promise = Promise.resolve(); #stopRequested = false; + readonly #stopSignal = new AbortController(); #stopping = false; #done = false; readonly #completion = Promise.withResolvers(); @@ -948,6 +960,7 @@ export class Launcher { return; } this.#stopRequested = true; + this.#stopSignal.abort(); this.#clearTimer(); this.#enqueue(async () => { // Let in-progress updates start replacements first; replacements observe the marker and own pre-activation tunnel cleanup. @@ -1043,11 +1056,14 @@ export class Launcher { async #startTrial(pending: PendingServiceUpdate): Promise { try { - await backupDatabaseOnce(this.#baseDir, pending); + await backupDatabaseOnce(this.#baseDir, pending, this.#stopSignal.signal); } catch { + // A stop aborts the copy and leaves the update pending for the next start. + if (this.#stopRequested) return; await this.#returnToPrevious(pending, "failed", "db-backup-failed"); return; } + if (this.#stopRequested) return; try { await this.#startChild(pending.targetVersion, "trial", pending); } catch { @@ -1202,8 +1218,6 @@ export class Launcher { this.#child = null; await this.#terminate(child.process); await finishOnlineBackup?.(); - // A stop during a minutes-long copy should not start a trial; restart resumes the pending update. - if (this.#stopRequested) return; await this.#startTrial(pending); } diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 4a0533da0b86..bebdeef9ece3 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -78,6 +78,7 @@ function layer(input: { Layer.provide( Layer.mock(GitVcsDriver.GitVcsDriver)({ execute: () => Effect.succeed(processOutput()), + invalidateRefs: () => Effect.void, ensureRemote: () => Effect.succeed("origin"), pushCurrentBranch: () => Effect.succeed({ @@ -220,7 +221,7 @@ it.effect.each(["--bare", "source.git"])( }); assert.strictEqual(result.cwd, destinationPath); assert.isTrue(yield* fs.exists(path.join(destinationPath, ".git"))); - }).pipe(Effect.provide(layer({ git: { execute: git.execute } }))); + }).pipe(Effect.provide(layer({ git }))); }).pipe( Effect.provide( GitVcsDriver.layer.pipe( @@ -235,6 +236,72 @@ it.effect.each(["--bare", "source.git"])( ), ); +it.effect.each([ + { readsWhen: "before git creates the repository", readBeforeInit: true }, + { readsWhen: "while HEAD is unborn", readBeforeInit: false }, +])("lists the cloned branches after a ref list was read $readsWhen", ({ readBeforeInit }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-clone-refs-" }); + const source = path.join(parent, "source"); + const destinationPath = path.join(parent, "checkout"); + const run = (cwd: string, args: ReadonlyArray) => + git.execute({ operation: "test.git", cwd, args }); + yield* run(parent, ["init", "--initial-branch=staging", source]); + yield* run(source, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "init", + ]); + const refNames = git + .listRefs({ cwd: destinationPath }) + .pipe(Effect.map((result) => result.refs.map((ref) => ref.name))); + let duringClone: ReadonlyArray | null = null; + // Stands in for git clone so the read lands at a chosen point inside it. + const cloneInTwoSteps = Effect.gen(function* () { + yield* fs.makeDirectory(destinationPath).pipe(Effect.orDie); + if (readBeforeInit) duringClone = yield* refNames; + yield* run(parent, ["init", "--initial-branch=staging", destinationPath]); + if (!readBeforeInit) duringClone = yield* refNames; + return yield* run(destinationPath, ["pull", source, "staging"]); + }); + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.cloneRepository({ remoteUrl: source, destinationPath }); + }).pipe( + Effect.provide( + layer({ + git: { + ...git, + execute: (input) => (input.args[0] === "clone" ? cloneInTwoSteps : git.execute(input)), + }, + }), + ), + ); + + assert.deepStrictEqual(duringClone, []); + assert.deepStrictEqual(yield* refNames, ["staging"]); + }).pipe( + Effect.provide( + GitVcsDriver.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-clone-refs-config-", + }), + ), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), +); + it.effect("reports clone progress from git's stderr and keeps its error text on failure", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 3db65b0bd5f9..b4b2dc0e92fe 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -429,6 +429,8 @@ export const make = Effect.gen(function* () { ), ); + // A ref list read while the clone ran saw an unborn HEAD and no refs. + yield* git.invalidateRefs(prepared.destinationPath); return { cwd: prepared.destinationPath, remoteUrl, diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 28d66e50bb0b..2a98c2ac399e 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -357,6 +357,7 @@ const cleanupFixture = ( let sessionLiveChecks = 0; let onLiveCheck: Effect.Effect = Effect.void; let outboxStatus: string | null = null; + let scheduledThreadIds: string[] = []; let fullStatusCalls = 0; let gitInspections = 0; let snapshotReads = 0; @@ -467,7 +468,9 @@ const cleanupFixture = ( ...threads.flatMap((thread) => thread.worktreePath ?? []), ...storedWorktreePaths, ].map((worktreePath) => ({ worktreePath })) - : [], + : strings.join("").includes("scheduled_tasks") + ? scheduledThreadIds.map((threadId) => ({ threadId })) + : [], )) as unknown as SqlClient.SqlClient), Effect.provideService(GitManager.GitManager, { invalidateStatus: () => Effect.void, @@ -533,6 +536,9 @@ const cleanupFixture = ( setOutboxStatus: (status: string | null) => { outboxStatus = status; }, + setScheduledThreadIds: (next: string[]) => { + scheduledThreadIds = next; + }, setDeletedThreads: (next: OrchestrationV2ThreadShell[]) => { deletedThreads = next; }, @@ -2332,6 +2338,114 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.effect("removes an idle No project thread's scratch folder past the retention", () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + const scratchRoot = `${fixture.config.baseDir}/scratch`; + const folder = `${scratchRoot}/quiet-otter`; + yield* fixture.fs.makeDirectory(folder, { recursive: true }); + yield* fixture.fs.writeFileString(`${folder}/notes.md`, "scratch\n"); + fixture.setProjectRoots([scratchRoot]); + fixture.setStoredWorktreePaths([]); + // Only this rule lets cleanup delete untracked files, which is all a scratch folder has. + fixture.setPolicy("tracked-changes"); + fixture.setThreads([ + shell({ worktreePath: folder, settledAt: at(-20 * DAY_MS), status: "completed" }), + ]); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ path: folder, outcome: "removed" }), + ]); + expect(yield* fixture.fs.exists(folder)).toBe(false); + }), + ), + ); + it.effect.each([ + { + name: "a Git repository in a subfolder", + policy: "tracked-changes", + repository: "clone/.git", + reason: "Contains a nested Git repository", + }, + { + name: "a Git repository of its own", + policy: "tracked-changes", + repository: ".git", + reason: "Contains a nested Git repository", + }, + { name: "files under the default keep rule", reason: "Has local files (notes.md)" }, + { + name: "a scheduled task", + policy: "tracked-changes", + scheduled: true, + reason: "Thread has a scheduled task", + }, + ] as const)( + "keeps an idle No project thread's scratch folder with $name", + ({ + policy, + repository, + scheduled, + reason, + }: { + readonly policy?: WorktreeKeepWhen; + readonly repository?: string; + readonly scheduled?: boolean; + readonly reason: string; + }) => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + const scratchRoot = `${fixture.config.baseDir}/scratch`; + const folder = `${scratchRoot}/quiet-otter`; + yield* fixture.fs.makeDirectory( + repository === undefined ? folder : `${folder}/${repository}`, + { recursive: true }, + ); + yield* fixture.fs.writeFileString(`${folder}/notes.md`, "scratch\n"); + // No thread owns this one, so it is listed and never removed. + const leftover = `${scratchRoot}/leftover`; + yield* fixture.fs.makeDirectory(leftover); + yield* fixture.fs.utimes(leftover, 1, 1); + fixture.setProjectRoots([scratchRoot]); + fixture.setStoredWorktreePaths([]); + if (policy !== undefined) fixture.setPolicy(policy); + if (scheduled) fixture.setScheduledThreadIds(["thread-1"]); + fixture.setThreads([ + shell({ worktreePath: folder, settledAt: at(-20 * DAY_MS), status: "completed" }), + ]); + const report = yield* fixture.service.runNow; + expect( + report.entries.map(({ path, outcome, reason }) => ({ path, outcome, reason })), + ).toEqual([ + { path: leftover, outcome: "kept", reason: "No thread uses this folder" }, + { path: folder, outcome: "kept", reason }, + ]); + expect(yield* fixture.fs.exists(`${folder}/notes.md`)).toBe(true); + }), + ), + ); + it.effect("keeps a thread's worktree folder that lost its .git link", () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + fixture.setThreads([ + shell({ branch: "feature", worktreePath: fixture.worktree, archivedAt: at(-DAY_MS) }), + ]); + yield* fixture.fs.remove(`${fixture.worktree}/.git`); + yield* fixture.fs.makeDirectory(`${fixture.worktree}/node_modules`); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ path: fixture.worktree, outcome: "kept" }), + ]); + expect(yield* fixture.fs.exists(`${fixture.worktree}/node_modules`)).toBe(true); + }), + ), + ); it.live("skips a stored worktree path whose parent is a regular file", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 0cf5eca0b80d..257fef03ae52 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -41,6 +41,7 @@ import { parse as parseYaml } from "yaml"; import * as ServerConfig from "./config.ts"; import * as GitManager from "./git/GitManager.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; +import { scratchFolderRoot } from "./project/ManagedProjectFolders.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; @@ -376,13 +377,14 @@ const make = Effect.gen(function* () { ); const measureWorktree = Effect.fn("storageCleanup.measureWorktree")( - function* (worktreePath: string, inspectRepositories = false) { + function* (worktreePath: string, inspectRepositories: boolean | "plain-folder" = false) { const root = yield* fs.realPath(worktreePath); const skipped = yield* sharedPnpmVirtualStore(root); const incomplete = { bytes: null, reason: "worktree could not be fully inspected" }; - const repositories = new Set([pathKey(root)]); + // A plain folder is not a checkout, so a repository at its root counts like one below it. + const repositories = new Set(inspectRepositories === "plain-folder" ? [] : [pathKey(root)]); const moduleStores: string[] = []; - if (inspectRepositories && (yield* fs.exists(path.join(root, ".gitmodules")))) { + if (inspectRepositories === true && (yield* fs.exists(path.join(root, ".gitmodules")))) { const submodules = yield* git.execute({ operation: "StorageCleanup.nestedRepositories", cwd: root, @@ -509,6 +511,7 @@ const make = Effect.gen(function* () { roots: ReadonlyArray, projects: ReadonlyArray<{ readonly workspaceRoot: string }>, entries: StorageCleanupReportEntry[], + scratchRoot: string | null, ) { const rows = yield* sql<{ worktreePath: string }>` SELECT DISTINCT json_extract(payload_json, '$.worktreePath') AS "worktreePath" @@ -547,12 +550,14 @@ const make = Effect.gen(function* () { (root) => !inside(root, managedRoot) && (pathKey(root) === pathKey(target) || inside(root, target)), ); - const directories = Effect.fnUntraced(function* (parent: string, managedRoot: string) { - if (inProject(parent, managedRoot)) return []; + // The scratch root is itself a project, so its folders skip the project check. + const directories = Effect.fnUntraced(function* (parent: string, managedRoot: string | null) { + const skipped = (target: string) => managedRoot !== null && inProject(target, managedRoot); + if (skipped(parent)) return []; const result: string[] = []; for (const name of yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) { const target = path.join(parent, name); - if (!inside(parent, target) || inProject(target, managedRoot)) continue; + if (!inside(parent, target) || skipped(target)) continue; const directory = yield* Effect.gen(function* () { if (pathKey(yield* fs.realPath(target)) !== pathKey(target)) return false; return (yield* fs.stat(target)).type === "Directory"; @@ -567,7 +572,7 @@ const make = Effect.gen(function* () { let firstMeasurement = true; const reported = new Set(); const report = Effect.fnUntraced( - function* (worktreePath: string) { + function* (worktreePath: string, scratch = false) { const key = pathKey(worktreePath); if ( reported.has(key) || @@ -595,7 +600,9 @@ const make = Effect.gen(function* () { outcome: "kept", reason: linked ? "No thread uses this worktree" - : "No thread uses this folder, and it isn't a git worktree", + : scratch + ? "No thread uses this folder" + : "No thread uses this folder, and it isn't a git worktree", path: worktreePath, projectPath: null, threadId: null, @@ -643,6 +650,8 @@ const make = Effect.gen(function* () { }).pipe(Effect.orElseSucceed(() => undefined)); } } + if (scratchRoot !== null) + for (const folder of yield* directories(scratchRoot, null)) yield* report(folder, true); for (const key of orphanSizes.keys()) { if (!reported.has(key)) orphanSizes.delete(key); } @@ -1000,6 +1009,32 @@ const make = Effect.gen(function* () { ); if (root !== null && !isFilesystemRoot(root, path)) roots.push(root); } + const scratchRoot = yield* fs + .realPath(scratchFolderRoot(config.baseDir, path)) + .pipe(Effect.orElseSucceed(() => null)); + // Nothing can bring a scratch folder's files back, so these hold whatever the retention says. + const scratchKeepReason = Effect.fn("StorageCleanup.scratchKeepReason")(function* ( + folder: string, + threadIds: ReadonlyArray, + rules: WorktreeCleanupRules, + ) { + const realParent = yield* fs.realPath(path.dirname(folder)); + if ((yield* fs.realPath(folder)) !== path.join(realParent, path.basename(folder))) + return "folder is a symbolic link"; + // Every file here is untracked, and only "tracked-changes" lets cleanup delete those. + if (rules.worktreeKeepWhen !== "tracked-changes") { + const file = (yield* fs.readDirectory(folder)).find((name) => name !== "node_modules"); + if (file !== undefined) return `has local files (${file})`; + } + // Each run of a scheduled task works on what its earlier runs left in the folder. + const scheduled = yield* sql<{ threadId: string }>` + SELECT thread_id AS "threadId" FROM scheduled_tasks + WHERE enabled = 1 AND thread_id IS NOT NULL + `.pipe(NodeSqliteClient.readOnly); + if (scheduled.some((task) => threadIds.includes(task.threadId))) + return "thread has a scheduled task"; + return null; + }); const worktreePathReason = Effect.fn("StorageCleanup.worktreePathReason")(function* ( worktreePath: string, ) { @@ -1010,8 +1045,10 @@ const make = Effect.gen(function* () { return "worktree is a symbolic link"; if (!roots.some((root) => inside(root, realPath))) return "outside the managed worktree folder"; - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") - return "not a linked worktree"; + const gitLink = yield* fs + .stat(path.join(worktreePath, ".git")) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)); + if (gitLink?.type !== "File") return "not a linked worktree"; return null; }); const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` @@ -1098,6 +1135,11 @@ const make = Effect.gen(function* () { const worktreeKey = yield* canonical(worktreePath); const worktreePaths = [worktreePath, worktreeKey]; const group = groups.get(worktreeKey) ?? []; + // No project threads have no branch; their folders sit directly in the scratch root. + const scratch = + thread.branch === null && + scratchRoot !== null && + pathKey(path.dirname(worktreeKey)) === pathKey(scratchRoot); const activityAt = Math.max(...group.map(storageCleanupActivityAt)); const deleted = "workspaceRoot" in thread; const settlementChain = deleted @@ -1145,7 +1187,7 @@ const make = Effect.gen(function* () { return keep(`shared by ${members.length} threads`); if (project === undefined) return keep("project is unavailable"); if ( - thread.branch === null || + (thread.branch === null && !scratch) || !group.every((member) => storageCleanupThreadIdle(member, now, serverSettings)) ) return keep(waitForStop("thread is running or has pending work")); @@ -1197,8 +1239,153 @@ const make = Effect.gen(function* () { : settings.worktreeUnchanged ? "not settled" : "no rules apply"; + // The merge and unchanged rules read Git, which a scratch folder does not have. + if (scratch && !eligible) + return keep( + settings.worktreeAfterDays !== null || settings.worktreeSettledAfterDays !== null + ? notDueReason + : "no rules apply", + ); // Only Git-based rules can make a not-yet-due worktree eligible, so skip Git otherwise. if (!eligible && !unchangedApplies && !settings.worktreeOnMerge) return keep(notDueReason); + // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds. + const blockedSinceCheck = Effect.fnUntraced(function* () { + const latestProjects = yield* projectStore.listShells(); + if (yield* containsProjectRoot(worktreePaths, [project, ...latestProjects])) + return "contains a project checkout"; + const bindings = yield* projections.getThreadWorktreePaths({ includeArchived: true }); + const bindingKeys = yield* Effect.forEach(bindings, (binding) => + canonical(binding.worktreePath), + ); + const latest = bindings.filter((_binding, index) => bindingKeys[index] === worktreeKey); + if (yield* hasTerminal(worktreePaths)) return waitForStop("open terminal"); + if (deleted) { + if ( + !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) + .worktreeOnDelete + ) + return "thread or cleanup settings changed since check"; + // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout. + const pendingCleanup = yield* sql<{ status: string }>` + SELECT status FROM orchestration_v2_effect_outbox + WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') + `.pipe(NodeSqliteClient.readOnly); + // Failed effects never retry, so only running work can finish and free the path. + if (pendingCleanup.some((row) => row.status !== "failed")) + return waitForStop("thread deletion is still pending"); + if (pendingCleanup.length > 0) return "thread deletion is still pending"; + } + const latestShells = yield* Effect.forEach(latest, (entry) => + projections.getThreadShell(entry.threadId), + ); + const latestGroup = latestShells.filter((shell) => shell !== null); + const latestKeys = yield* Effect.forEach(latestGroup, (shell) => + shell.worktreePath === null ? Effect.succeed(null) : canonical(shell.worktreePath), + ); + const latestOwner = worktreeOwner(deleted ? [thread, ...latestGroup] : latestGroup); + if ( + latestGroup.length !== group.length || + latestOwner?.id !== thread.id || + latestOwner?.branch !== thread.branch || + latestGroup.some((shell, index) => { + const previous = group.find((member) => member.id === shell.id); + return ( + previous === undefined || + shell.branch !== thread.branch || + !Equal.equals(shell.settledAt, previous.settledAt) || + (shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) || + !storageCleanupThreadIdle(shell, now, serverSettings) || + latestKeys[index] !== worktreeKey || + storageCleanupActivityAt(shell) !== storageCleanupActivityAt(previous) + ); + }) + ) + return waitForStop("thread activity or shared worktree changed since check"); + if (settlementChain.length > 1) { + const ancestors = yield* Effect.forEach(settlementChain.slice(1), (ancestor) => + projections.getThreadShell(ancestor.id), + ); + const latestSettlement = worktreeSettlementChain( + latestGroup.find((shell) => shell.id === thread.id)!, + new Map( + [...latestGroup, ...ancestors.filter((ancestor) => ancestor !== null)].map( + (ancestor) => [ancestor.id, ancestor], + ), + ), + now, + serverSettings, + ).at(-1); + if ( + latestSettlement?.id !== settledThread?.id || + !Equal.equals(latestSettlement?.settledAt, settledThread?.settledAt) || + !Equal.equals(latestSettlement?.updatedAt, settledThread?.updatedAt) + ) + return "parent settlement changed since check"; + } + // Sessions can outlive their run and can be shared across app threads. + const sessionRows = yield* sql<{ payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_provider_sessions + WHERE status != 'stopped' + `.pipe(NodeSqliteClient.readOnly); + const sessions = yield* Effect.forEach(sessionRows, (row) => + decodeCleanupSession(row.payload_json), + ); + // Ready persisted sessions may have released their runtime; only live sessions protect the path. + for (const session of sessions) { + if ( + (yield* occupies(worktreePaths, session.cwd)) && + (yield* providerSessions.isLive(session.id)) + ) + return waitForStop("provider session is still open"); + } + return null; + }); + const settingsChangedSinceCheck = Effect.gen(function* () { + const currentSettings = yield* settingsService.getSettings; + const current = resolveWorktreeCleanup(currentSettings, thread.projectId); + return ( + currentSettings.autoResumeLimitedThreads !== serverSettings.autoResumeLimitedThreads || + currentSettings.snoozeLimitedThreads !== serverSettings.snoozeLimitedThreads || + Object.keys(settings).some( + (key) => + current[key as keyof typeof settings] !== settings[key as keyof typeof settings], + ) + ); + }); + if (scratch) { + const threadIds = group.map((member) => member.id); + const blocked = + (yield* scratchKeepReason(worktreePath, threadIds, settings)) ?? + (yield* blockedSinceCheck()); + if (blocked !== null) return keep(blocked); + const inspection = yield* measureWorktree(worktreePath, "plain-folder"); + if (inspection.reason !== null) return keep(inspection.reason); + return yield* withWorkspaceLease( + worktreePath, + Effect.gen(function* () { + // Walked again under the lease: a repository deleted with the folder is gone for good. + const finalInspection = yield* measureWorktree(worktreePath, "plain-folder"); + if (finalInspection.reason !== null) return keep(finalInspection.reason); + // The walk takes time, so the cheap checks run last, right before removal. + const blockedWhileMeasuring = + (yield* scratchKeepReason(worktreePath, threadIds, settings)) ?? + (yield* blockedSinceCheck()); + if (blockedWhileMeasuring !== null) return keep(blockedWhileMeasuring); + if (yield* settingsChangedSinceCheck) return keep("settings changed since check"); + yield* fs.remove(worktreePath, { recursive: true }); + entries.push({ + ...entry, + outcome: "removed", + reason: sentence(removalReason), + bytes: inspection.bytes, + }); + // The stored path stays so the next turn recreates the folder, empty. + yield* Effect.logInfo("storage cleanup removed scratch folder", { + threadId: thread.id, + }); + }), + ); + } const pathReason = yield* worktreePathReason(worktreePath); if (pathReason !== null) return keep(pathReason); if (yield* containsProjectRoot(worktreePaths, [project, ...snapshot.projects])) @@ -1309,98 +1496,6 @@ const make = Effect.gen(function* () { return keep(integrated ? notDueReason : "has commits beyond the project branch"); } if (!eligible) return keep(notDueReason); - // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds. - const blockedSinceCheck = Effect.fnUntraced(function* () { - const latestProjects = yield* projectStore.listShells(); - if (yield* containsProjectRoot(worktreePaths, [project, ...latestProjects])) - return "contains a project checkout"; - const bindings = yield* projections.getThreadWorktreePaths({ includeArchived: true }); - const bindingKeys = yield* Effect.forEach(bindings, (binding) => - canonical(binding.worktreePath), - ); - const latest = bindings.filter((_binding, index) => bindingKeys[index] === worktreeKey); - if (yield* hasTerminal(worktreePaths)) return waitForStop("open terminal"); - if (deleted) { - if ( - !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) - .worktreeOnDelete - ) - return "thread or cleanup settings changed since check"; - // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout. - const pendingCleanup = yield* sql<{ status: string }>` - SELECT status FROM orchestration_v2_effect_outbox - WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') - `.pipe(NodeSqliteClient.readOnly); - // Failed effects never retry, so only running work can finish and free the path. - if (pendingCleanup.some((row) => row.status !== "failed")) - return waitForStop("thread deletion is still pending"); - if (pendingCleanup.length > 0) return "thread deletion is still pending"; - } - const latestShells = yield* Effect.forEach(latest, (entry) => - projections.getThreadShell(entry.threadId), - ); - const latestGroup = latestShells.filter((shell) => shell !== null); - const latestKeys = yield* Effect.forEach(latestGroup, (shell) => - shell.worktreePath === null ? Effect.succeed(null) : canonical(shell.worktreePath), - ); - const latestOwner = worktreeOwner(deleted ? [thread, ...latestGroup] : latestGroup); - if ( - latestGroup.length !== group.length || - latestOwner?.id !== thread.id || - latestOwner?.branch !== thread.branch || - latestGroup.some((shell, index) => { - const previous = group.find((member) => member.id === shell.id); - return ( - previous === undefined || - shell.branch !== thread.branch || - !Equal.equals(shell.settledAt, previous.settledAt) || - (shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) || - !storageCleanupThreadIdle(shell, now, serverSettings) || - latestKeys[index] !== worktreeKey || - storageCleanupActivityAt(shell) !== storageCleanupActivityAt(previous) - ); - }) - ) - return waitForStop("thread activity or shared worktree changed since check"); - if (settlementChain.length > 1) { - const ancestors = yield* Effect.forEach(settlementChain.slice(1), (ancestor) => - projections.getThreadShell(ancestor.id), - ); - const latestSettlement = worktreeSettlementChain( - latestGroup.find((shell) => shell.id === thread.id)!, - new Map( - [...latestGroup, ...ancestors.filter((ancestor) => ancestor !== null)].map( - (ancestor) => [ancestor.id, ancestor], - ), - ), - now, - serverSettings, - ).at(-1); - if ( - latestSettlement?.id !== settledThread?.id || - !Equal.equals(latestSettlement?.settledAt, settledThread?.settledAt) || - !Equal.equals(latestSettlement?.updatedAt, settledThread?.updatedAt) - ) - return "parent settlement changed since check"; - } - // Sessions can outlive their run and can be shared across app threads. - const sessionRows = yield* sql<{ payload_json: string }>` - SELECT payload_json FROM orchestration_v2_projection_provider_sessions - WHERE status != 'stopped' - `.pipe(NodeSqliteClient.readOnly); - const sessions = yield* Effect.forEach(sessionRows, (row) => - decodeCleanupSession(row.payload_json), - ); - // Ready persisted sessions may have released their runtime; only live sessions protect the path. - for (const session of sessions) { - if ( - (yield* occupies(worktreePaths, session.cwd)) && - (yield* providerSessions.isLive(session.id)) - ) - return waitForStop("provider session is still open"); - } - return null; - }); const blocked = yield* blockedSinceCheck(); if (blocked !== null) return keep(blocked); // Inspect before the lease; provider startup must not wait for directory traversal. @@ -1423,18 +1518,7 @@ const make = Effect.gen(function* () { return keep("commit changed since check"); const finalChanges = yield* localChangesReason(worktreePath, settings); if (finalChanges !== null) return keep(finalChanges); - const currentSettings = yield* settingsService.getSettings; - const current = resolveWorktreeCleanup(currentSettings, thread.projectId); - if ( - currentSettings.autoResumeLimitedThreads !== - serverSettings.autoResumeLimitedThreads || - currentSettings.snoozeLimitedThreads !== serverSettings.snoozeLimitedThreads || - Object.keys(settings).some( - (key) => - current[key as keyof typeof settings] !== settings[key as keyof typeof settings], - ) - ) - return keep("settings changed since check"); + if (yield* settingsChangedSinceCheck) return keep("settings changed since check"); // Clean only untracked files. Ordinary worktrees retain Git's final tracked-edit check. if (settings.worktreeKeepWhen === "tracked-changes") yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); @@ -1480,6 +1564,7 @@ const make = Effect.gen(function* () { roots, yield* projectStore.list({ includeDeleted: true }), entries, + scratchRoot, ); return { waiting, kept }; }); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index eb27174d5ed6..da37b82144ac 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -13,6 +13,8 @@ import { ProviderDriverKind, ProviderInstanceId, retainedTerminalHistory, + terminalSequenceEnd, + terminalSequenceStateAfter, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -337,7 +339,6 @@ function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): s if (terminated) lines.pop(); const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n"); const capped = terminated ? `${retained}\n` : retained; - if (Buffer.byteLength(capped) <= maxBytes) return capped; const points = Array.from(capped); let start = points.length; let bytes = 0; @@ -347,7 +348,11 @@ function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): s bytes += next; start -= 1; } - return points.slice(start).join(""); + const kept = points.slice(start).join(""); + if (maxLines <= 0 || maxBytes <= 0) return kept; + // A cut inside a control sequence drops the rest of that sequence too. + const state = terminalSequenceStateAfter(0, text, 0, text.length - kept.length); + return kept.slice(terminalSequenceEnd(state, kept)); } it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequences, and clear", () => { @@ -365,6 +370,10 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen "\u001b[31m", "\u001b[0m", "\u001b]8;;url\u0007", + "\u001b]0;", + "\u001bP", + "\u0007", + "\u001b\\", "\ud83d", "\ude80", ]; @@ -375,7 +384,8 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen for (const maxBytes of [0, 3, 8, 64, Infinity]) { for (const maxLines of [0, 1, 3, 5, 5_000]) { - let expected = retainedHistory("before\ninitial\n", maxLines, maxBytes); + let full = "before\ninitial\n"; + let expected = retainedHistory(full, maxLines, maxBytes); const history = new TerminalManager.BoundedTerminalHistory( maxLines, "before\ninitial\n", @@ -386,18 +396,33 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen for (let step = 0; step < 300; step += 1) { if (step % 73 === 0) { history.clear(); - expected = ""; - expect(history.value()).toBe(expected); + full = ""; + expect(history.value()).toBe(""); } const chunk = nextFragment() + nextFragment(); history.append(chunk); - expected = retainedHistory(expected + chunk, maxLines, maxBytes); + full += chunk; + expected = retainedHistory(full, maxLines, maxBytes); expect(history.value()).toBe(expected); } } } }); +it("does not retain the tail of a control sequence whose introducer was trimmed", () => { + for (const [text, maxBytes, retained] of [ + ["prefix\u001b[31mOK", 6, "OK"], + ["prefix\u001b]0;HIDDEN\u0007OK", 10, "OK"], + ["prefix\u001b]0;🙂HIDDEN\u0007OK", 9, "OK"], + // libghostty leaves every string but OSC at the emoji's second byte, so HELLO was visible. + ["prefix\u001bP🙂HELLO\u001b\\OK", 9, "HELLO\u001b\\OK"], + ] as const) { + expect(new TerminalManager.BoundedTerminalHistory(5_000, text, maxBytes).value()).toBe( + retained, + ); + } +}); + it("bounds long partial lines and joins surrogate pairs across chunk boundaries", () => { const maxBytes = 65_539; let expected = ""; @@ -1172,6 +1197,24 @@ it.layer( }), ); + it.effect("restores a log tail that starts inside a control sequence without its remainder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const [persisted, restored] of [ + ["\u001b]0;x\u0007ABCD", "ABCD"], + ["名\u001b]0;名\u0007ABCD", "ABCD"], + ["\u001b]0;x\u0007\u001b[31mABCD", "ABCD"], + ] as const) { + const { manager, logsDir } = yield* createManager(5, { historyByteLimit: 8 }); + const path = yield* historyLogPath(logsDir); + yield* fs.makeDirectory(logsDir, { recursive: true }); + yield* fs.writeFileString(path, persisted); + expect((yield* manager.open(openInput())).history).toBe(restored); + expect(yield* fs.readFileString(path)).toBe(restored); + } + }), + ); + it.effect("persists small terminal updates without rewriting the retained history", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1426,6 +1469,15 @@ it.layer( { name: "NUL in DECRQM", data: "A\u001b[?2026\u0000$pB", history: "AB" }, { name: "CR in DSR", data: "AB\u001b[5\rnC", history: "AB\rC" }, { name: "DLE in DSR", data: "AB\u001b[5\u0010nC", history: "ABC" }, + // Ghostty reads non-ASCII there as UTF-8 bytes: it drops é and runs 0x80-0x9F as C1 controls. + { name: "é in DA", data: "A\u001b[0écB", history: "AB" }, + { name: "DECID byte in a CSI", data: "A\u001b[嚠B", history: "A\ufffdB" }, + { + name: "C1 DCS then C1 CSI in DSR", + data: "A\u001b[\u0090\u009b6nB", + history: "A\u001bP\u009cB", + }, + { name: "ESC in DSR", data: "A\u001b[5\u001b[6nB", history: "AB" }, ])( "strips a query with an embedded control but keeps what the terminal ran ($name)", ({ data, history }) => @@ -1441,6 +1493,19 @@ it.layer( }), ); + // Ghostty aborts a CSI at a non-ASCII code point and prints it and what follows. + it.effect("keeps a CSI interrupted by non-ASCII text in history", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + ptyAdapter.processes[0]!.emitData("A\u001b[界nB"); + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(reopened.history.split("\u001b[?1049h")[0]).toBe("A\u001b[界nB"); + }), + ); + it.effect.each([ { name: "OSC", sequence: "\u001b]0;unfinished" }, { name: "DCS", sequence: "\u001bPunfinished" }, diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index dd06f5f51695..b9a279666e8f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -6,6 +6,9 @@ import { DEFAULT_TERMINAL_ID, DEFAULT_MAX_TERMINAL_BUFFER_BYTES, retainedTerminalHistory, + TERMINAL_SEQUENCE_GROUND, + terminalSequenceEnd, + terminalSequenceStateAfter, TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, @@ -888,6 +891,14 @@ interface TerminalHistoryPersistenceCursor { readonly fileBytes: number; } +function countLineBreaks(data: string): number { + let lineBreaks = 0; + for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) { + lineBreaks += 1; + } + return lineBreaks; +} + export class BoundedTerminalHistory { private readonly maxLines: number; private readonly maxBytes: number; @@ -897,6 +908,8 @@ export class BoundedTerminalHistory { private lineBreaks = 0; // Reading the old string's tail on each append can force chunk concatenation. private lastCodeUnit: number | undefined; + // The terminal parser's state at the first retained code unit, which is where a replay starts. + private sequenceState = TERMINAL_SEQUENCE_GROUND; private cachedValue: string | null = ""; private generation = 0; private appendedLength = 0; @@ -961,10 +974,7 @@ export class BoundedTerminalHistory { private appendChunk(data: string): void { const byteLength = Buffer.byteLength(data); - let lineBreaks = 0; - for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) { - lineBreaks += 1; - } + const lineBreaks = countLineBreaks(data); const previous = this.chunks.at(-1); if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) { previous.data += data; @@ -981,6 +991,7 @@ export class BoundedTerminalHistory { private discardChunk(): void { const first = this.chunks[this.start]!; + this.sequenceState = terminalSequenceStateAfter(this.sequenceState, first.data); this.byteLength -= first.byteLength; this.lineBreaks -= first.lineBreaks; this.chunks[this.start++] = undefined; @@ -992,6 +1003,7 @@ export class BoundedTerminalHistory { this.discardChunk(); return; } + this.sequenceState = terminalSequenceStateAfter(this.sequenceState, first.data, 0, offset); first.data = first.data.slice(offset); first.byteLength -= byteLength; first.lineBreaks -= lineBreaks; @@ -1039,6 +1051,12 @@ export class BoundedTerminalHistory { } this.trimChunk(offset, bytes, lineBreaks); } + // A cut inside a control sequence would replay the rest of it as text. + while (this.sequenceState !== TERMINAL_SEQUENCE_GROUND && this.start < this.chunks.length) { + const first = this.chunks[this.start]!; + const dropped = first.data.slice(0, terminalSequenceEnd(this.sequenceState, first.data)); + this.trimChunk(dropped.length, Buffer.byteLength(dropped), countLineBreaks(dropped)); + } if ( this.start === this.chunks.length || (this.start > 2_048 && this.start * 2 >= this.chunks.length) @@ -1057,6 +1075,7 @@ export class BoundedTerminalHistory { this.byteLength = 0; this.lineBreaks = 0; this.lastCodeUnit = undefined; + this.sequenceState = TERMINAL_SEQUENCE_GROUND; this.cachedValue = ""; } @@ -1194,18 +1213,70 @@ function stripStringTerminator(value: string): string { return value; } -// BEL ends only OSC; inside DCS, PM and APC it is payload. A decoded U+009C is the -// reverse: libghostty keeps it inside OSC but ends the other strings there. -function findStringTerminatorIndex(input: string, start: number, isOsc: boolean): number | null { +interface C1Effect { + /** ESC form of the sequence the code point starts, or "" when it returns to ground. */ + readonly next: string; + /** Its 0x9A byte ran DECID, which replies with device attributes. */ + readonly query: boolean; + /** What replays its visible effect from ground. */ + readonly safe: string; +} + +// Outside ground and OSC, libghostty parses a code point's UTF-8 bytes one at a time: 0x80-0x9F +// act as C1 controls, the rest are dropped. Bytes left over after a return to ground print U+FFFD. +function c1Effect(codePoint: number): C1Effect | undefined { + // A lone surrogate is encoded as U+FFFD, whose bytes are all dropped. + if (codePoint >= 0xd800 && codePoint <= 0xdfff) return undefined; + let next: string | undefined; + let query = false; + let safe = ""; + for (let shift = codePoint > 0xffff ? 12 : codePoint > 0x7ff ? 6 : 0; shift >= 0; shift -= 6) { + const byte = 0x80 | ((codePoint >> shift) & 0x3f); + if (next === "") { + safe += "\ufffd"; + } else if (next?.startsWith("\u001b]")) { + next += "\ufffd"; + } else if (byte === 0x9b) { + next = "\u001b["; + } else if (byte === 0x9d) { + next = "\u001b]"; + } else if (byte === 0x90) { + next = "\u001bP"; + } else if (byte === 0x98 || byte === 0x9e || byte === 0x9f) { + next = "\u001b_"; + } else if (byte < 0xa0) { + next = ""; + query = byte === 0x9a; + // Any other C1 control but ST runs as ESC plus the byte less 0x40. + safe = query || byte === 0x9c ? "" : `\u001b${String.fromCharCode(byte - 0x40)}`; + } + } + return next === undefined ? undefined : { next, query, safe }; +} + +// BEL ends only OSC; inside DCS, PM and APC it is payload. Non-ASCII is the reverse: libghostty +// keeps it inside OSC, while a C1 byte in it ends the other strings. +function findStringEnd( + input: string, + start: number, + isOsc: boolean, +): { end: number; c1?: { effect: C1Effect; index: number } } | null { for (let index = start; index < input.length; index += 1) { const codePoint = input.charCodeAt(index); - if ((isOsc ? codePoint === 0x07 : codePoint === 0x9c) || isControlSequenceCancel(codePoint)) { - return index + 1; + if ((isOsc && codePoint === 0x07) || isControlSequenceCancel(codePoint)) { + return { end: index + 1 }; } // Like xterm.js, an ESC that does not start ST ends the string and begins the next sequence. if (codePoint === 0x1b) { if (index + 1 >= input.length) return null; - return input.charCodeAt(index + 1) === 0x5c ? index + 2 : index; + return { end: input.charCodeAt(index + 1) === 0x5c ? index + 2 : index }; + } + if (codePoint >= 0x80 && !isOsc) { + const fullCodePoint = input.codePointAt(index) ?? codePoint; + const end = index + (fullCodePoint > 0xffff ? 2 : 1); + const effect = c1Effect(fullCodePoint); + if (effect) return { end, c1: { effect, index } }; + index = end - 1; } } return null; @@ -1251,6 +1322,8 @@ function sanitizeTerminalHistoryChunk( const pendingScanStart = Math.max(0, pendingControlSequence.length - 1); let visibleText = ""; let index = 0; + // Set when a C1 code point began the sequence at `index`: the ESC form that stands in for it. + let c1Introducer = ""; const append = (value: string) => { visibleText += value; @@ -1259,14 +1332,18 @@ function sanitizeTerminalHistoryChunk( while (index < input.length) { const codePoint = input.charCodeAt(index); - if (codePoint === 0x1b) { - const nextCodePoint = input.charCodeAt(index + 1); + if (c1Introducer.length > 0 || codePoint === 0x1b) { + // Empty for a sequence the input introduces itself, which starts with ESC at `index`. + const prefix = c1Introducer; + c1Introducer = ""; + const contentStart = prefix.length > 0 ? index : index + 2; + const nextCodePoint = (prefix || input).charCodeAt(prefix.length > 0 ? 1 : index + 1); if (Number.isNaN(nextCodePoint)) { return { visibleText, pendingControlSequence: input.slice(index) }; } if (nextCodePoint === 0x5b) { - let cursor = index + 2; + let cursor = contentStart; // Inside a CSI Ghostty executes 0x00-0x0F and ignores DEL and 0x10-0x1F, which it would // print in ground state. None join the parameters; only executed ones survive a strip. let body = ""; @@ -1276,25 +1353,47 @@ function sanitizeTerminalHistoryChunk( const cancelled = isControlSequenceCancel(cursorCodePoint); if (cancelled || isCsiFinalByte(cursorCodePoint)) { if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(input.slice(index, cursor + 1)); + append(prefix + input.slice(index, cursor + 1)); } else { append(executedControls); } index = cursor + 1; break; } + if (cursorCodePoint >= 0x80) { + const fullCodePoint = input.codePointAt(cursor) ?? cursorCodePoint; + const end = cursor + (fullCodePoint > 0xffff ? 2 : 1); + const c1 = c1Effect(fullCodePoint); + if (c1) { + // The CSI ends here undispatched, so it is kept only when the terminal is back in + // ground with nothing to answer. + if (c1.next.length === 0 && !c1.query) { + append(prefix + input.slice(index, end)); + } else { + append(executedControls + c1.safe); + } + c1Introducer = c1.next; + index = end; + break; + } + cursor = end; + continue; + } + // An ESC abandons the CSI and starts the next sequence, which may be stripped. + if (cursorCodePoint === 0x1b) { + append(executedControls); + index = cursor; + break; + } if (cursorCodePoint < 0x10) { executedControls += input[cursor]; - } else if ( - (cursorCodePoint >= 0x20 || cursorCodePoint === 0x1b) && - cursorCodePoint !== 0x7f - ) { + } else if (cursorCodePoint >= 0x20 && cursorCodePoint !== 0x7f) { body += input[cursor]; } cursor += 1; } if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return { visibleText, pendingControlSequence: prefix + input.slice(index) }; } continue; } @@ -1305,23 +1404,32 @@ function sanitizeTerminalHistoryChunk( nextCodePoint === 0x5e || nextCodePoint === 0x5f ) { - const terminatorIndex = findStringTerminatorIndex( + const stringEnd = findStringEnd( input, - Math.max(index + 2, pendingScanStart), + Math.max(contentStart, pendingScanStart), nextCodePoint === 0x5d, ); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; + if (stringEnd === null) { + return { visibleText, pendingControlSequence: prefix + input.slice(index) }; } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); + const { end, c1 } = stringEnd; + const content = stripStringTerminator( + prefix.slice(2) + input.slice(contentStart, c1?.index ?? end), + ); const strip = (nextCodePoint === 0x5d && shouldStripOscSequence(content)) || (nextCodePoint === 0x50 && shouldStripDcsSequence(content)); - if (!strip) { - append(sequence); + if (c1 === undefined || (!strip && c1.effect.next.length === 0 && !c1.effect.query)) { + if (!strip) { + append(prefix + input.slice(index, end)); + } + } else { + // ST stands in for a code point that cannot be replayed as it came. + const closed = strip ? "" : `${prefix}${input.slice(index, c1.index)}\u009c`; + append(closed + c1.effect.safe); + c1Introducer = c1.effect.next; } - index = terminatorIndex; + index = end; continue; } @@ -1344,7 +1452,7 @@ function sanitizeTerminalHistoryChunk( index = nextSequenceStart; } - return { visibleText, pendingControlSequence: "" }; + return { visibleText, pendingControlSequence: c1Introducer }; } function legacySafeThreadId(threadId: string): string { @@ -2087,6 +2195,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const info = yield* file.stat; const limit = BigInt(historyByteLimit); const offset = info.size > limit ? info.size - limit : 0n; + // The tail can start inside a control sequence; only the bytes before it say which. + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + let sequenceState = TERMINAL_SEQUENCE_GROUND; + const skipped = new Uint8Array(Math.min(Number(offset), 64 * 1024)); + for (let remaining = Number(offset); remaining > 0;) { + const read = Number(yield* file.read(skipped.subarray(0, remaining))); + if (read === 0) break; + remaining -= read; + sequenceState = terminalSequenceStateAfter( + sequenceState, + decoder.decode(skipped.subarray(0, read), { stream: true }), + ); + } yield* file.seek(offset, "start"); const bytes = new Uint8Array(Number(info.size - offset)); let length = 0; @@ -2100,8 +2221,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func // A tail read can start inside a UTF-8 code point. Skip its remaining bytes. while (start < length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1; } + sequenceState = terminalSequenceStateAfter( + sequenceState, + decoder.decode(bytes.subarray(0, start)), + ); + const history = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + bytes.subarray(start, length), + ); return { - history: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(start, length)), + history: history.slice(terminalSequenceEnd(sequenceState, history)), truncated: offset > 0n, }; }); diff --git a/apps/server/src/testUtils/fakeWsl.ts b/apps/server/src/testUtils/fakeWsl.ts new file mode 100644 index 000000000000..d8f88fcde8db --- /dev/null +++ b/apps/server/src/testUtils/fakeWsl.ts @@ -0,0 +1,37 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +// `wslpath` honours the distro's configurable automount root, so the fake maps drives under a temp dir. +export const makeFakeWslHost = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mountRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-wsl-mnt-" }); + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-wsl-bin-" }); + const wslpath = path.join(binDir, "wslpath"); + yield* fileSystem + .writeFileString( + wslpath, + [ + "#!/bin/sh", + '[ "$1" = "-u" ] || exit 1', + "p=$(printf '%s' \"$2\" | tr '\\\\' '/')", + "drive=$(printf '%s' \"$p\" | cut -c1 | tr 'A-Z' 'a-z')", + `printf '%s/%s%s\\n' '${mountRoot}' "$drive" "\${p#?:}"`, + "", + ].join("\n"), + ) + .pipe(Effect.orDie); + yield* fileSystem.chmod(wslpath, 0o755).pipe(Effect.orDie); + const environment = { + ...HostProcess.Environment.defaultValue(), + PATH: `${binDir}:${HostProcess.Environment.defaultValue().PATH ?? ""}`, + }; + const underWsl = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, environment), + ); + return { mountRoot, underWsl }; +}); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index ae5c32bf5743..e6e43d606e27 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -58,6 +58,7 @@ interface FakeCodexInput { mcpServers?: ReadonlyArray<{ name: string; enabled: boolean }> | number; requiredCwd?: string; requireInstructions?: string; + requireMcpServersOff?: ReadonlyArray; } function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { @@ -73,6 +74,7 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { mcpServers: input.mcpServers ?? [], requiredCwd: input.requiredCwd ?? null, requireInstructions: input.requireInstructions ?? null, + requireMcpServersOff: input.requireMcpServersOff ?? [], stderr: input.stderr ?? null, output: input.output, exitCode: input.exitCode ?? 0, @@ -129,6 +131,33 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { ' process.stderr.write(message + "\\n");', " process.exit(code);", "}", + "// Like Codex: a later mcp_servers={...} override replaces servers earlier -c flags defined.", + "const servers = new Map();", + "const offServers = new Set();", + "for (let index = 0; index < args.length; index += 1) {", + ' if (args[index] !== "-c" && args[index] !== "--config") continue;', + ' const value = args[index + 1] ?? "";', + ' if (value.startsWith("mcp_servers={")) {', + ' const named = [...value.matchAll(/"([^"]+)"=\\{enabled=false\\}/g)].map((match) => match[1]);', + " for (const name of named) offServers.add(name);", + " for (const name of [...servers.keys()]) {", + " if (named.includes(name)) servers.set(name, false);", + " else servers.delete(name);", + " }", + ' } else if (value.startsWith("mcp_servers.")) {', + ' const [name, key] = value.slice("mcp_servers.".length).split(/[.=]/);', + " if (value === `mcp_servers.${name}.enabled=false`) offServers.add(name);", + " // An enabled flag alone toggles a server another layer (config.toml) defines.", + ' if (key === "enabled" && !servers.has(name)) continue;', + ' servers.set(name, servers.get(name) === true || key === "url" || key === "command");', + " }", + "}", + "for (const [name, hasTransport] of servers) {", + ' if (!hasTransport) fail("Error loading config.toml: invalid transport in `mcp_servers." + name + "`", 12);', + "}", + "for (const name of check.requireMcpServersOff) {", + ' if (!offServers.has(name)) fail("MCP server left on: " + name, 13);', + "}", 'if (check.requiredCwd !== null && process.cwd() !== check.requiredCwd) fail("cannot inspect checkout", 10);', 'if (check.requireInstructions !== null && !instructions.includes(check.requireInstructions)) fail("missing writer task", 11);', "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {", @@ -365,7 +394,7 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { { name: "already-off", enabled: false }, ], requireArg: - '--config features.plugins=false --config features.apps=false --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}}', + 'exec --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}} --config developer_instructions="Use', }, (textGeneration) => Effect.gen(function* () { @@ -384,6 +413,52 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); + it.effect("turns off an MCP server that launch args define without dropping its transport", () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Remove default shapes" }), + launchArgs: `-c 'mcp_servers.t3-code.url="http://127.0.0.1:1/mcp"' -c 'mcp_servers.t3-code.enabled=true'`, + mcpServers: [{ name: "t3-code", enabled: true }], + requireMcpServersOff: ["t3-code"], + requireArg: + "--config features.apps=false --config mcp_servers.t3-code.enabled=false --ephemeral", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Remove default shapes from characters", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Remove default shapes"); + }), + ), + ); + + it.effect("keeps MCP servers off when launch args set their own mcp_servers table", () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Remove default shapes" }), + launchArgs: `-c 'mcp_servers={"my.server"={command="echo",enabled=true}}'`, + mcpServers: [ + { name: "my.server", enabled: true }, + { name: "my server", enabled: true }, + ], + requireArg: + '--config features.apps=false --config mcp_servers={"my.server"={enabled=false},"my server"={enabled=false}} --config mcp_servers.my server.enabled=false --ephemeral', + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Remove default shapes from characters", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Remove default shapes"); + }), + ), + ); + it.effect("still generates when Codex cannot list its MCP servers", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 4de5392502c8..49c60166111b 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -55,6 +55,11 @@ const codexMcpServersOff = (names: ReadonlyArray): string | null => names.length === 0 ? null : `mcp_servers={${names.map((name) => `${JSON.stringify(name)}={enabled=false}`).join(",")}}`; +// Codex splits -c keys at the first "=" and on every dot, so only those names take a dotted flag. +const codexMcpServerOffFlags = (names: ReadonlyArray) => + names + .filter((name) => !name.includes(".") && !name.includes("=")) + .flatMap((name) => ["--config", `mcp_servers.${name}.enabled=false`]); const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( codexConfig: CodexSettings, @@ -291,20 +296,28 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } : {}), }; - const mcpServersOff = yield* listEnabledMcpServers({ + const enabledMcpServers = yield* listEnabledMcpServers({ binary, cwd, env, launchArgs: codexMcpListLaunchArgs(launchArgs), - }).pipe(Effect.map(codexMcpServersOff)); + }); + const mcpServersOff = codexMcpServersOff(enabledMcpServers); + const execLaunchArgs = codexExecLaunchArgs(launchArgs); + // A launch-arg mcp_servers={...} table replaces ours and can re-enable dotted names. + const launchArgsSetMcpTable = execLaunchArgs.some((arg) => /\bmcp_servers\s*=/.test(arg)); const spawnCommand = yield* resolveSpawnCommand( binary, [ "exec", - ...codexExecLaunchArgs(launchArgs), + // Before launch args: a later table would replace servers their -c flags define. + ...(mcpServersOff ? ["--config", mcpServersOff] : []), + ...execLaunchArgs, // A user's MCP server could act on the text-generation prompt. ...CODEX_NO_PLUGIN_ARGS, - ...(mcpServersOff ? ["--config", mcpServersOff] : []), + ...(mcpServersOff && launchArgsSetMcpTable ? ["--config", mcpServersOff] : []), + // After launch args, so a launch arg's enabled=true cannot turn a server back on. + ...codexMcpServerOffFlags(enabledMcpServers), "--ephemeral", "--skip-git-repo-check", "-s", diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index ae3e468d099e..d7e73a1da96c 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,11 +1,21 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { + ProviderDriverKind, + SecretProviderId, + ServerConfigStreamEvent, + UsageLimitSourceId, + type ServerProviderUsageWindow, + type UsageLimitSourceSnapshot, +} from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -26,6 +36,14 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => "hub.test:8317", "Usage limit source", ]); + // Only an http(s) hub URL names an endpoint an agent can be sent to. + expect(snapshots.map((source) => source.origin)).toEqual([ + undefined, + undefined, + undefined, + "https://hub.test:8317", + undefined, + ]); expect(() => encodeConfigEvent({ version: 1, @@ -155,3 +173,135 @@ it.effect("reads a management key from its secret provider", () => { ]), ); }); + +const NOW = Date.parse("2026-11-13T12:00:00.000Z"); +const at = (hours: number) => DateTime.formatIso(DateTime.makeUnsafe(NOW + hours * 3_600_000)); +const spent = (id: string, hours: number): ServerProviderUsageWindow => ({ + id, + kind: "weekly", + label: id, + usedPercent: 100, + resetsAt: at(hours), +}); +const hub = ( + accounts: ReadonlyArray<{ + driver?: string; + windows?: ReadonlyArray; + unavailable?: true; + }>, +): UsageLimitSourceSnapshot => ({ + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "hub", + checkedAt: at(0), + accounts: accounts.map((account, index) => ({ + id: `account-${index}`, + driver: ProviderDriverKind.make(account.driver ?? "claudeAgent"), + usageLimits: { + checkedAt: at(0), + windows: account.windows ?? [], + ...(account.unavailable ? { unavailable: { reason: "probeFailed" as const } } : {}), + }, + })), +}); + +it.each([ + { + name: "earliest refill when every account is spent, each at its latest spent window", + accounts: [ + { windows: [spent("five_hour", 1), spent("seven_day", 3)] }, + { windows: [spent("five_hour", 2)] }, + { driver: "codex", windows: [] }, + ], + model: "claude-haiku-5-5", + expected: at(2), + }, + { + name: "unknown while an account still has room", + accounts: [{ windows: [spent("five_hour", 1)] }, { windows: [] }], + model: "claude-haiku-5-5", + expected: null, + }, + { + name: "unknown while an account could not be read", + accounts: [{ windows: [spent("five_hour", 1)] }, { unavailable: true as const }], + model: "claude-haiku-5-5", + expected: null, + }, + { + name: "unknown when a spent window has no reset or already reset", + accounts: [{ windows: [{ ...spent("five_hour", 1), resetsAt: undefined }] }], + model: "claude-haiku-5-5", + expected: null, + }, + { + name: "a model-scoped weekly window blocks only its own model", + accounts: [{ windows: [spent("seven_day_fable", 5)] }], + model: "claude-haiku-5-5", + expected: null, + }, + { + name: "a model-scoped weekly window blocks its own model", + accounts: [{ windows: [spent("seven_day_fable", 5)] }], + model: "claude-fable-5-2", + expected: at(5), + }, + { + name: "no account of the driver", + accounts: [{ driver: "codex", windows: [spent("five_hour", 1)] }], + model: "claude-haiku-5-5", + expected: null, + }, +])("hub pool reset: $name", ({ accounts, model, expected }) => { + expect( + UsageLimitSources.hubPoolResetAt( + [hub(accounts)], + ProviderDriverKind.make("claudeAgent"), + model, + NOW, + ), + ).toBe(expected); +}); + +it.effect("the reset fallback refreshes its hub, and a stalled hub cannot hold the failure", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const roomLeft = { ...hub([{ windows: [spent("five_hour", 2)] }]), origin: "http://hub.test" }; + const cached = { + ...roomLeft, + accounts: roomLeft.accounts.map((account) => ({ + ...account, + usageLimits: { + ...account.usageLimits, + windows: account.usageLimits.windows.map((window) => ({ ...window, usedPercent: 99 })), + }, + })), + }; + let current: ReadonlyArray = [cached]; + let refresh: Effect.Effect = Effect.sync(() => { + current = [roomLeft]; + }); + const lookup = UsageLimitSources.UsageLimitResetFallback.pipe( + Effect.flatMap((fallback) => + fallback.resetAt(ProviderDriverKind.make("claudeAgent"), "http://hub.test", null), + ), + Effect.provide( + UsageLimitSources.layerResetFallback.pipe( + Layer.provide( + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.sync(() => current), + refresh: Effect.suspend(() => refresh), + }), + ), + ), + ), + ); + // The last poll saw room; the account was spent after it. + expect(yield* lookup).toBe(at(2)); + current = [cached]; + refresh = Effect.never; + const stalled = yield* Effect.forkChild(lookup); + yield* TestClock.adjust("5 seconds"); + expect(yield* Fiber.join(stalled)).toBeNull(); + }), +); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ece..c59193184341 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -3,6 +3,7 @@ import { UsageLimitSourceError, type UsageLimitSourceConsumeResetCreditInput, type ProviderConsumeResetCreditResult, + type ProviderDriverKind, type ServerSettings, UsageLimitSourceConfig, settingsSecretLiteral, @@ -26,6 +27,7 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretReso import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import * as Settings from "../serverSettings.ts"; +import { windowLimitsModel } from "../provider/claudeUsageLimits.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; export class UsageLimitSources extends Context.Service< @@ -79,7 +81,14 @@ export const make = Effect.gen(function* () { config: UsageLimitSourceConfig, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; + const url = URL.parse(config.url); + const base = { + id, + kind: config.kind, + label: sourceLabel(id, config), + ...(url && /^https?:$/.test(url.protocol) ? { origin: url.origin } : {}), + checkedAt, + } as const; const managementKey = yield* readManagementKey(config); if (managementKey.length === 0) { return { ...base, accounts: [], error: missingManagementKey(config) }; @@ -198,3 +207,64 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(UsageLimitSources, make).pipe( Layer.provide(SecretResolverLayer.layer), ); + +/** A hub answers with a usage limit only when every account it routes the driver to is spent. */ +export function hubPoolResetAt( + sources: ReadonlyArray, + driver: ProviderDriverKind, + model: string | null, + now: number, +): string | null { + let earliest: number | null = null; + for (const source of sources) { + for (const account of source.accounts) { + if (account.driver !== driver) continue; + // An unread account may be the one still blocked, so its refill is unknown. + if (account.usageLimits.unavailable) return null; + const spent = account.usageLimits.windows.filter( + (window) => window.usedPercent >= 100 && windowLimitsModel(window, model), + ); + if (spent.length === 0) return null; + const resets = spent.map((window) => Date.parse(window.resetsAt ?? "")); + if (resets.some((reset) => !Number.isFinite(reset))) return null; + const recoversAt = Math.max(...resets); + if (recoversAt <= now) return null; + earliest = earliest === null ? recoversAt : Math.min(earliest, recoversAt); + } + } + return earliest === null ? null : DateTime.formatIso(DateTime.makeUnsafe(earliest)); +} + +export class UsageLimitResetFallback extends Context.Reference<{ + readonly resetAt: ( + driver: ProviderDriverKind, + endpoint: string, + model: string | null, + ) => Effect.Effect; +}>("t3/usage/UsageLimitResetFallback", { + defaultValue: () => ({ resetAt: () => Effect.succeed(null) }), +}) {} + +export const layerResetFallback = Layer.effect( + UsageLimitResetFallback, + Effect.gen(function* () { + const sources = yield* UsageLimitSources; + return { + resetAt: Effect.fn("UsageLimitResetFallback.resetAt")(function* (driver, endpoint, model) { + // Only the hub at the instance's endpoint serves its turns. + const atEndpoint = Effect.map(sources.current, (current) => + current.filter((source) => source.origin === endpoint), + ); + if ((yield* atEndpoint).length === 0) return null; + // The pool usually ran out after the last poll, so the cached accounts still show room. + yield* sources.refresh.pipe(Effect.timeout("5 seconds"), Effect.ignore); + return hubPoolResetAt( + yield* atEndpoint, + driver, + model, + DateTime.toEpochMillis(yield* DateTime.now), + ); + }), + }; + }), +); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 81f3d1ec289a..9c616eb02ac1 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -51,6 +51,7 @@ import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts"; import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usage"; import * as UsageService from "./UsageService.ts"; +import { DESKTOP_HOST_SCOPE_ENV, physicalSourceIdOf } from "./usageTranscriptReader.ts"; import * as MuseSdk from "../../../../packages/provider-muse/src/server/sdk.ts"; import { MuseUsageHome } from "@t3tools/provider-muse/server"; import { makeScanCacheWriter, SCAN_CACHE_FILE_NAME } from "./usageScanCache.ts"; @@ -1440,6 +1441,39 @@ describe("UsageService", () => { }), ); + it.effect("identifies a transcript directory created after the server first read usage", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const scope = "0123456789abcdef0123456789abcdef"; + const dir = NodePath.join(home, "pi", "sessions"); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const piFingerprint = (summary: UsageSummary) => + summary.sources.find((source) => source.fingerprint.provider === "pi")?.fingerprint; + const missing = piFingerprint(yield* service.readSummary(WINDOW)); + assert.strictEqual(missing?.resolvedHomePath, dir); + assert.isUndefined(missing?.physicalSourceId); + yield* Effect.promise(() => NodeFSP.mkdir(dir, { recursive: true })); + assert.strictEqual( + piFingerprint(yield* service.readSummary(WINDOW))?.physicalSourceId, + physicalSourceIdOf(scope, yield* Effect.promise(() => NodeFSP.realpath(dir))), + ); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-physical-source-created", + home, + settings, + // The native Windows server reads identity from realpath alone. + platform: "win32", + environment: { [DESKTOP_HOST_SCOPE_ENV]: scope }, + }), + ), + ); + }), + ); + it.effect( "reports missing Pi history and skips malformed records without losing valid usage", () => @@ -3106,7 +3140,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v15.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v16.json"); assert.deepStrictEqual( (yield* Effect.promise(() => NodeFSP.readdir(stateDir))) .filter((name) => name.startsWith("usage-scan-cache")) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index a55532913c3c..fbbc1205868e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -42,8 +42,10 @@ import type { import { canonicalModelFilter, resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { + DESKTOP_HOST_SCOPE_ENV, listTranscriptFiles, readDirectoryVolumeId, + readPhysicalSourceId, readTranscriptRecords, } from "./usageTranscriptReader.ts"; import { @@ -92,6 +94,7 @@ interface TranscriptSource { readonly format: TranscriptUsageFormat; readonly dir: string; readonly volumeId: string; + readonly physicalSourceId?: string; readonly fileName?: string; } @@ -158,6 +161,24 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcess.Environment; + const hostPlatform = yield* HostProcess.Platform; + const hostScope = hostEnvironment[DESKTOP_HOST_SCOPE_ENV]?.trim(); + // A mount or a directory's on-disk name does not change under a running server. + // A miss is not cached: the directory may not exist yet. + const physicalSourceIds = new Map>(); + const physicalSourceId = (dir: string) => + Effect.promise(() => { + const cached = physicalSourceIds.get(dir); + if (cached !== undefined) return cached; + const id = readPhysicalSourceId(dir, hostScope, hostPlatform); + physicalSourceIds.set(dir, id); + void id.then((resolved) => { + if (resolved === undefined && physicalSourceIds.get(dir) === id) { + physicalSourceIds.delete(dir); + } + }); + return id; + }); const hostname = yield* HostProcess.Hostname; const readerContext = yield* Effect.context(); @@ -385,11 +406,13 @@ export const make = Effect.gen(function* () { const key = `${provider}\0${dir}`; if (seen.has(key)) continue; seen.add(key); + const sourceId = yield* physicalSourceId(dir); dirs.push({ provider, format, dir, volumeId, + ...(sourceId === undefined ? {} : { physicalSourceId: sourceId }), ...(fileName === undefined ? {} : { fileName }), }); } @@ -629,6 +652,7 @@ export const make = Effect.gen(function* () { readonly provider: UsageProviderKind; readonly dir: string; readonly volumeId: string; + readonly physicalSourceId?: string; readonly hostId?: string; readonly status?: UsageSource["status"]; readonly message?: string; @@ -649,16 +673,22 @@ export const make = Effect.gen(function* () { windowStartMs: number, fromMemory: boolean, ) { - const { provider, format, dir, volumeId, fileName } = source; + const { provider, format, dir, volumeId, physicalSourceId, fileName } = source; + const identity = { + provider, + dir, + volumeId, + ...(physicalSourceId === undefined ? {} : { physicalSourceId }), + }; const exists = yield* fileSystem .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (!exists) { cover(source, windowStartMs); - return { provider, dir, volumeId, files: null } satisfies ScannedDir; + return { ...identity, files: null } satisfies ScannedDir; } // scanSummary aggregates the saved files, as it does for a deleted directory. - if (fromMemory) return { provider, dir, volumeId, files: [] } satisfies ScannedDir; + if (fromMemory) return { ...identity, files: [] } satisfies ScannedDir; const { files, walked, failedPaths } = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), ); @@ -704,9 +734,7 @@ export const make = Effect.gen(function* () { "usage.files_parsed": read.filter((file) => file.update !== undefined).length, }); return { - provider, - dir, - volumeId, + ...identity, files: parsedFiles, walked, ...(unread > 0 @@ -736,14 +764,19 @@ export const make = Effect.gen(function* () { Effect.flatMap((sources) => Effect.forEach(sources, ({ volumeId, ...source }) => Effect.map( + // A reader's own volumeId names a remote account, not a local directory. volumeId === undefined - ? Effect.promise(() => readDirectoryVolumeId(source.dir)) - : Effect.succeed(volumeId), - (resolved): ScannedDir => ({ + ? Effect.all([ + Effect.promise(() => readDirectoryVolumeId(source.dir)), + physicalSourceId(source.dir), + ]) + : Effect.succeed<[string, string | undefined]>([volumeId, undefined]), + ([resolved, sourceId]): ScannedDir => ({ ...source, files: source.files?.map((file) => ({ ...file, malformedRecords: 0 })) ?? null, provider: reader.provider, volumeId: resolved, + ...(sourceId === undefined ? {} : { physicalSourceId: sourceId }), }), ), ), @@ -972,7 +1005,17 @@ export const make = Effect.gen(function* () { for (const [ index, - { provider, dir, volumeId, files, status, message, refreshing, hostId: sourceHostId }, + { + provider, + dir, + volumeId, + physicalSourceId, + files, + status, + message, + refreshing, + hostId: sourceHostId, + }, ] of scannedDirs.entries()) { let scannedFiles = 0; let skippedFiles = 0; @@ -1019,7 +1062,13 @@ export const make = Effect.gen(function* () { const sourceMessage = message ?? (files === null ? "No transcript directory on this environment." : null); sources.push({ - fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId }, + fingerprint: { + hostId: sourceHostId ?? hostId, + provider, + resolvedHomePath: dir, + volumeId, + ...(physicalSourceId === undefined ? {} : { physicalSourceId }), + }, status: files === null && scannedFiles === 0 ? "missing" diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 84291da51a18..09348d5818a2 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -285,6 +285,46 @@ describe("scan cache round trip", () => { ...codex(0), position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, }); + // The migrated entries persist without a state and load again under the current version. + expect(decodeScanCache(parse(makeScanCacheWriter()(restored, {})), TEST_FORMAT_MAP)).toEqual( + restored, + ); + }); + + it("restarts a grown v15 Codex entry, whose baseline time can be older than its totals", () => { + const request = { + input_tokens: 100, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 120, + }; + const entry: CachedFile = { + size: 10, + mtimeMs: 100, + provider: "codex", + records: [record({ provider: "codex", model: "gpt-6-astra", dedupeKey: null })], + tailRecords: [], + malformedRecords: 0, + position: position({ + state: { + ...initialCodexScanState(), + model: "gpt-6-astra", + lastCumulativeUsage: request, + latestCumulativeAtMs: 1_786_000_000_000, + }, + }), + }; + const current = parse(makeScanCacheWriter()(new Map([["/codex.jsonl", entry]]), {})); + + expect( + decodeScanCache({ ...current, version: 15 }, TEST_FORMAT_MAP).get("/codex.jsonl"), + ).toEqual({ + ...entry, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); + expect(decodeScanCache(current, TEST_FORMAT_MAP).get("/codex.jsonl")).toEqual(entry); }); it("re-parses a Claude transcript cached before 1-hour cache writes were counted", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index b571dd3708f6..e75691d2b846 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -10,7 +10,7 @@ import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { mergeUsageCopies } from "./usageAggregation.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 15 as const; +const USAGE_SCAN_CACHE_VERSION = 16 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -18,8 +18,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * the newer one; older files are removed once it is saved. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v15.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v16.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v15.json", "usage-scan-cache-v14.json", "usage-scan-cache-v13.json", "usage-scan-cache-v12.json", @@ -308,9 +309,10 @@ export function decodeScanCache( // A corrupt state disqualifies the entry: resuming with it would attach // appended usage to the wrong model or replay fork-copied history. if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue; - // Before v15 the state lacks the latest snapshot time that keeps a re-emitted older snapshot - // from billing twice, so a grown file re-parses whole instead of resuming. - const resumable = !legacy && !(entry.p === "codex" && version < 15); + // Before v16 a Codex baseline can lack the time of its latest move, or carry an older one, + // and that time keeps a re-emitted older snapshot from billing twice. A grown file re-parses + // whole instead of resuming. + const resumable = !legacy && !(entry.p === "codex" && version < 16); const provider = entry.p; const records = decodeRecords(entry.r, provider); diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index f4cb72ec1e25..ca994dd3ac5b 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -13,6 +13,8 @@ import * as Schema from "effect/Schema"; import { TEST_FORMATS } from "./usageTestFormats.ts"; import { listTranscriptFiles, + physicalSourceIdOf, + readPhysicalSourceId, readTranscriptRecords, transcriptLineGate, } from "./usageTranscriptReader.ts"; @@ -577,3 +579,49 @@ describe("transcriptLineGate", () => { } }); }); + +describe("readPhysicalSourceId", () => { + const scope = "0123456789abcdef0123456789abcdef"; + const windowsPath = "C:\\Users\\User\\.claude\\projects"; + + // One NTFS directory the Windows server reads natively and the WSL server + // through drvfs. The temp dir stands in for the drive's mount point. + async function drvfsFixture(extraMounts: (escapedMountPoint: string) => string = () => "") { + const mountPoint = NodePath.join(dir, "drive c"); + await NodeFSP.mkdir(NodePath.join(mountPoint, "Users", "User", ".claude", "projects"), { + recursive: true, + }); + const escaped = mountPoint.replaceAll(" ", "\\040"); + const mountInfoPath = NodePath.join(dir, "mountinfo"); + await NodeFSP.writeFile( + mountInfoPath, + [ + "24 1 8:32 / / rw,relatime shared:1 - ext4 /dev/sdc rw,discard", + `81 24 0:61 / ${escaped} rw,noatime shared:2 - 9p C:\\134 rw,dirsync,aname=drvfs;path=C:\\;uid=1000;gid=1000;symlinkroot=/mnt/,mmap,access=client,msize=65536,trans=fd`, + extraMounts(escaped), + ].join("\n"), + ); + return { mountPoint, mountInfoPath }; + } + + it("names a drvfs directory as the Windows server names it, whatever its typed case", async () => { + const { mountPoint, mountInfoPath } = await drvfsFixture(); + const wslId = await readPhysicalSourceId( + `${mountPoint}/users/user/.claude/projects`, + scope, + "linux", + mountInfoPath, + ); + assert.strictEqual(wslId, physicalSourceIdOf(scope, windowsPath)); + }); + + it("gives no identity to native or shadowing Linux mounts, or without a desktop scope", async () => { + const { mountPoint, mountInfoPath } = await drvfsFixture( + (escaped) => `90 81 8:48 / ${escaped}/Users rw,relatime - ext4 /dev/sdd rw`, + ); + const projects = `${mountPoint}/Users/User/.claude/projects`; + assert.isUndefined(await readPhysicalSourceId(projects, scope, "linux", mountInfoPath)); + assert.isUndefined(await readPhysicalSourceId(dir, scope, "linux", mountInfoPath)); + assert.isUndefined(await readPhysicalSourceId(projects, undefined, "linux", mountInfoPath)); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index a4b10a4df204..5b92f48356aa 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off // Cold windows span ~1.4 GB/1,500 files; node:fs buffer streaming is ~10x cheaper than materializing files. +import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -150,6 +151,137 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +/** + * Set by the desktop app, from a value it persists, on its Windows server and + * the WSL server it manages, so both can name one Windows directory alike + * without merging separate machines that share a hostname and path. + */ +export const DESKTOP_HOST_SCOPE_ENV = "T3CODE_DESKTOP_HOST_SCOPE"; + +const MOUNT_FIELD_ESCAPE = /\\([0-7]{3})/g; +const WINDOWS_ROOT = /^(?:[A-Za-z]:|\\\\[^\\]+\\[^\\]+)\\?$/; + +const unescapeMountField = (field: string) => + field.replace(MOUNT_FIELD_ESCAPE, (_, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)), + ); + +const normalizeWindowsPath = (path: string) => + (/^[a-z]:/.test(path) ? path[0]!.toUpperCase() + path.slice(1) : path).replace(/\\$/, ""); + +/** + * The Windows directory behind the deepest mount holding `dir`, read from + * `/proc/self/mountinfo` so a custom automount root resolves and an ext4 + * mount under `/mnt` never passes for a drive. + */ +function windowsMountFor( + mountInfo: string, + dir: string, +): { readonly mountPoint: string; readonly windowsRoot: string } | undefined { + let deepest: { mountPoint: string; windowsRoot: string | undefined } | undefined; + for (const line of mountInfo.split("\n")) { + const fields = line.split(" "); + const separator = fields.indexOf("-", 6); + if (separator === -1) continue; + const mountPoint = unescapeMountField(fields[4] ?? ""); + const contains = + mountPoint === "/" || + dir === mountPoint || + dir.startsWith(mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`); + if (!contains || (deepest !== undefined && deepest.mountPoint.length > mountPoint.length)) { + continue; + } + // WSL2 mounts drives over 9p with `aname=drvfs;path=C:\`; WSL1 as `drvfs`. + const options = (fields[separator + 3] ?? "").split(/[,;]/); + const source = options.includes("aname=drvfs") + ? options.find((option) => option.startsWith("path="))?.slice("path=".length) + : fields[separator + 1] === "drvfs" + ? unescapeMountField(fields[separator + 2] ?? "") + : undefined; + const root = unescapeMountField(fields[3] ?? "/") + .split("/") + .filter(Boolean); + deepest = { + mountPoint, + windowsRoot: + source !== undefined && WINDOWS_ROOT.test(source) + ? [normalizeWindowsPath(source), ...root].join("\\") + : undefined, + }; + } + return deepest?.windowsRoot === undefined + ? undefined + : { mountPoint: deepest.mountPoint, windowsRoot: deepest.windowsRoot }; +} + +/** + * drvfs keeps the casing a path was typed with, while Windows reports the + * on-disk names. Read those names rather than folding case, which would join + * distinct entries of a case-sensitive directory. + */ +async function onDiskNames( + mountPoint: string, + parts: readonly string[], +): Promise { + const names: string[] = []; + let current = mountPoint; + for (const part of parts) { + let entries: string[]; + try { + entries = await NodeFSP.readdir(current); + } catch { + return undefined; + } + const folded = part.toUpperCase(); + const [name, ...others] = entries.includes(part) + ? [part] + : entries.filter((entry) => entry.toUpperCase() === folded); + if (name === undefined || others.length > 0) return undefined; + names.push(name); + current = NodePath.posix.join(current, name); + } + return names; +} + +export function physicalSourceIdOf(scope: string, windowsPath: string): string { + return NodeCrypto.createHash("sha256") + .update(`${scope}\0${windowsPath}`) + .digest("hex") + .slice(0, 32); +} + +/** + * Identifies a Windows directory the same way from the Windows server and from + * a WSL server reading it through drvfs. Directories that are not Windows + * backed, and servers the desktop app did not start, get none. + */ +export async function readPhysicalSourceId( + dir: string, + scope: string | undefined, + platform: NodeJS.Platform, + mountInfoPath = "/proc/self/mountinfo", +): Promise { + if (scope === undefined || scope.length === 0) return undefined; + try { + if (platform === "win32") { + // The native realpath reports on-disk casing; the JS one keeps the input's. + return physicalSourceIdOf(scope, normalizeWindowsPath(await NodeFSP.realpath(dir))); + } + if (platform !== "linux") return undefined; + const mount = windowsMountFor(await NodeFSP.readFile(mountInfoPath, "utf8"), dir); + if (mount === undefined) return undefined; + const names = await onDiskNames( + mount.mountPoint, + dir.slice(mount.mountPoint.length).split("/").filter(Boolean), + ); + return names === undefined + ? undefined + : physicalSourceIdOf(scope, [mount.windowsRoot, ...names].join("\\")); + } catch { + return undefined; + } +} + async function guardMatches( handle: NodeFSP.FileHandle, position: TranscriptParsePosition, diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 89580e0937af..a8c0052452ed 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -861,6 +861,63 @@ it.effect.each([1_700_000_000, 1_700_000_000.9999])( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("a branch switch landing after the snapshot belongs to the next checkpoint", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-switch-race-" }); + const git = (args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd, args }); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* fileSystem.writeFileString(path.join(cwd, "f.txt"), "A\n"); + yield* git(["add", "."]); + yield* git(["commit", "-m", "a"]); + yield* git(["checkout", "-b", "b"]); + yield* fileSystem.writeFileString(path.join(cwd, "f.txt"), "B\n"); + yield* git(["commit", "-am", "b"]); + yield* git(["checkout", "-"]); + const [first, second, third] = ["1", "2", "3"].map((turn) => + CheckpointRef.make(`refs/t3/checkpoints/switch-race/${turn}`), + ) as [CheckpointRef, CheckpointRef, CheckpointRef]; + const racedDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: Effect.fn(function* (input: VcsProcess.VcsProcessInput) { + const result = yield* liveProcess.run(input); + if (input.args.includes("write-tree")) yield* git(["checkout", "b"]).pipe(Effect.orDie); + return result; + }), + }), + ); + const changedSince = (fromCheckpointRef: CheckpointRef, toCheckpointRef: CheckpointRef) => + Effect.gen(function* () { + const refs = { cwd, fromCheckpointRef, toCheckpointRef }; + return { + patch: yield* driver.checkpoints.diffCheckpoints({ ...refs, ignoreWhitespace: false }), + authored: yield* driver.checkpoints.listAuthoredPaths(refs), + }; + }); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: first }); + yield* racedDriver.checkpoints.captureCheckpoint({ + cwd, + checkpointRef: second, + previousCheckpointRef: first, + }); + yield* driver.checkpoints.captureCheckpoint({ + cwd, + checkpointRef: third, + previousCheckpointRef: second, + }); + + assert.deepEqual(yield* changedSince(first, second), { patch: "", authored: null }); + assert.deepEqual(yield* changedSince(second, third), { patch: "", authored: null }); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("checkpoint capture preserves racy edits made after resetting the index", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1051,6 +1108,7 @@ it.effect.each(["committed", "unborn"] as const)( assert.deepStrictEqual(commands, [ "rev-parse", + ...(head === "committed" ? ["log"] : []), "config", ...(head === "committed" ? ["read-tree", "ls-files"] : []), "add", @@ -1273,6 +1331,87 @@ it.effect("restores a checkpoint before the first commit without staging its fil }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("restores edits to skip-worktree files whose checkpoint content matches HEAD", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-skip-worktree-restore-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const skippedPath = path.join(cwd, "skipped.txt"); + yield* fileSystem.writeFileString(skippedPath, "original\n"); + yield* git(["add", "skipped.txt"]); + yield* git(["commit", "-m", "add skipped file"]); + yield* git(["update-index", "--skip-worktree", "skipped.txt"]); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* fileSystem.writeFileString(skippedPath, "later\n"); + + assert.isTrue( + yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef, fallbackToHead: false }), + ); + + assert.strictEqual(yield* fileSystem.readFileString(skippedPath), "original\n"); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect("sparse restore rewrites manual skip-worktree files and keeps exclusions absent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + // One byte per chunk, so the index listing splits inside every record. + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + const onStdoutChunk = input.onStdoutChunk; + return liveProcess.run( + onStdoutChunk === undefined + ? input + : { + ...input, + onStdoutChunk: (chunk) => { + for (let index = 0; index < chunk.length; index++) + onStdoutChunk(chunk.subarray(index, index + 1)); + }, + }, + ); + }, + }), + ); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sparse-restore-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + for (const name of ["in/normal", "in/skipped", "in/s space", "out/deep/absent", "out/stray"]) { + yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, name), "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "sparse fixture"]); + yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "in"]); + // Keeps Git from clearing the bit on files that are present. + yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); + yield* git(["update-index", "--skip-worktree", "in/skipped", "in/s space"]); + const captureCwd = path.join(cwd, "in"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef }); + yield* fs.writeFileString(path.join(cwd, "in/skipped"), "later\n"); + yield* fs.writeFileString(path.join(cwd, "in/s space"), "later\n"); + yield* fs.writeFileString(path.join(cwd, "in/normal"), "later\n"); + + for (const restoreCwd of [captureCwd, cwd]) { + assert.isTrue( + yield* driver.checkpoints.restoreCheckpoint({ + cwd: restoreCwd, + checkpointRef, + fallbackToHead: false, + }), + ); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, "in/skipped")), "original\n"); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, "in/s space")), "original\n"); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, "in/normal")), "original\n"); + assert.isFalse(yield* fs.exists(path.join(cwd, "out"))); + } + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + // The real VCS process, spawning `command` wherever the driver runs git. const vcsProcessSpawning = (command: string) => Layer.effect( diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index ff0eac7c0009..4084e531f230 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -458,6 +458,8 @@ export class GitVcsDriver extends Context.Service< input: VcsSwitchRefInput, ) => Effect.Effect; readonly initRepo: (input: VcsInitInput) => Effect.Effect; + /** Drops cached refs after something outside this driver changed them, such as a clone into `cwd`. */ + readonly invalidateRefs: (cwd: string) => Effect.Effect; readonly listLocalBranchNames: (cwd: string) => Effect.Effect; } >()("t3/vcs/GitVcsDriver") {} @@ -467,6 +469,10 @@ const FLAGGED_INDEX_ENTRIES_MAX_BYTES = 1024 * 1024; const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64; const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds"; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; +// Reflog subjects that only point a ref at an existing commit. Anything else counts as creating +// one, so an unknown subject keeps its commit attributed. +const REF_MOVE_REFLOG_SUBJECT = + /^(?:$|(?:checkout|reset|branch|clone|fetch)\b|[^:]*\((?:start|finish|abort|reset)\): |(?:merge|pull|rebase|cherry-pick)\b[^()]*: fast-forward$)/i; const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "-c", "core.fsmonitor=false", @@ -585,6 +591,10 @@ const gitCommand = Effect.fn("GitVcsDriver.gitCommand")( }), ); +const HEAD_MOVES_SCAN_LIMIT = 512; +const HEAD_MARK_TRAILER = "T3-Head-Moved-At"; +const HEAD_MARK_TRAILER_PATTERN = new RegExp(`^${HEAD_MARK_TRAILER}: (\\d+) (\\d+)$`, "m"); + export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -922,13 +932,14 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const headExists = result.exitCode === 0; const headChecked = headExists || result.exitCode === 1; const lines = result.stdout.split("\n"); - const [gitCommonDir = "", indexPath = "", prefix = ""] = lines; + const [gitCommonDir = "", indexPath = "", prefix = "", head = ""] = lines; if (headChecked && lines.length === (headExists ? 5 : 4) && lines.at(-1) === "") { return { gitCommonDir: gitCommonDir.trim(), indexPath: indexPath.trim(), prefix, headExists, + head: headExists ? head : undefined, }; } // Git before 2.31 echoes --path-format, and a path containing a newline spans lines. @@ -944,6 +955,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( indexPath: undefined, prefix: prefixResult.stdout.replace(/\n$/, ""), headExists: headChecked ? headExists : yield* hasHeadCommit(cwd), + head: undefined, }; }); @@ -965,6 +977,64 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( // core.longpaths is disabled in the user's Git configuration. const longPaths = hostPlatform === "win32" ? ["-c", "core.longpaths=true"] : []; + // Newest first: the commit each HEAD move landed on, when, and Git's reason for it. + const readHeadMoves = (cwd: string) => + execute({ + operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, + cwd, + args: [ + "-c", + "log.showSignature=false", + "log", + "--walk-reflogs", + "-n", + String(HEAD_MOVES_SCAN_LIMIT), + "--date=unix", + "--format=%H %gd %gs", + "HEAD", + "--", + ], + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => + result.exitCode !== 0 + ? [] + : result.stdout.split("\n").flatMap((line) => { + const [, to, at, reason] = /^([0-9a-f]+) HEAD@\{(\d+)\} (.*)$/.exec(line) ?? []; + return to === undefined || at === undefined || reason === undefined + ? [] + : [{ to, at, reason }]; + }), + ), + Effect.orElseSucceed(() => []), + ); + + // A checkpoint taken after a branch switch has a second parent: the checkpoint before it with + // the switch applied, which is where that turn started. Other ranges start at `from` itself. + const resolveTurnStart = (input: VcsDriver.VcsListAuthoredPathsInput) => + execute({ + operation: "GitVcsDriver.checkpoints.resolveTurnStart", + cwd: input.cwd, + args: [ + "-c", + "log.showSignature=false", + "log", + "--no-walk=unsorted", + "--format=%H %P", + `${input.fromCheckpointRef}^{commit}`, + `${input.toCheckpointRef}^2`, + ], + allowNonZeroExit: true, + }).pipe( + Effect.map((result): string => { + const [fromLine = "", startLine = ""] = result.stdout.trimEnd().split("\n"); + const [start = "", , startedFrom] = startLine.split(" "); + return result.exitCode === 0 && startedFrom === fromLine.split(" ")[0] + ? start + : input.fromCheckpointRef; + }), + ); + const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; @@ -979,9 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "-c", "splitIndex.sharedIndexExpire=never", ]; - const { gitCommonDir, indexPath, prefix, headExists } = yield* resolveCaptureRepository( + const { gitCommonDir, indexPath, prefix, headExists, head } = yield* resolveCaptureRepository( input.cwd, ); + // Read before the snapshot and pinned to the same HEAD: a switch that lands while files are + // being staged then belongs to the next checkpoint instead of being applied to this one. + const headMoves = head === undefined ? [] : yield* readHeadMoves(input.cwd); + const lastMove = headMoves.at(0); + // Moves share a second, so the mark also counts how many of that second it has seen. + const headMark = + lastMove !== undefined && lastMove.to === head + ? `${lastMove.at} ${headMoves.filter((move) => move.at === lastMove.at).length}` + : undefined; const indexId = yield* random.uuidV4(); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const hostEnv = yield* HostProcess.Environment; @@ -1331,8 +1410,137 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); } - const message = `t3 checkpoint ref=${input.checkpointRef}`; - // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. + // Switching branches rewrites files the turn never wrote, and only HEAD's reflog tells a + // switch from a reset. The trailer marks where that reflog stood for the next checkpoint. + const turnStart = + headMark === undefined || input.previousCheckpointRef === undefined + ? null + : yield* Effect.gen(function* () { + const previous = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "-c", + "log.showSignature=false", + "log", + "--no-walk", + "--format=%H %T %ct %P%n%B", + `${input.previousCheckpointRef}^{commit}`, + ], + allowNonZeroExit: true, + }); + if (previous.exitCode !== 0) return null; + const [commit = "", tree = "", capturedAt = "", previousHead = ""] = previous.stdout + .slice(0, previous.stdout.indexOf("\n")) + .split(" "); + const [, markedAt, markedCount] = + HEAD_MARK_TRAILER_PATTERN.exec(previous.stdout) ?? []; + const oldestMarked = headMoves.findLastIndex((move) => move.at === markedAt); + // -1 when the scan ended inside the marked second, so the count cannot be placed. + const since = + oldestMarked === HEAD_MOVES_SCAN_LIMIT - 1 + ? -1 + : oldestMarked - Number(markedCount) + 1; + if (headMoves[since]?.to !== previousHead) return null; + const switches = headMoves + .slice(0, since) + .map((move, index) => ({ ...move, from: headMoves[index + 1]!.to })) + .filter( + (move) => + move.reason.startsWith("checkout: moving from ") && move.from !== move.to, + ) + .toReversed(); + const landedOn = switches.at(-1)?.to; + if (landedOn === undefined) return null; + + yield* execute({ + operation, + cwd: input.cwd, + args: [...indexConfig, "-c", "index.sparse=false", "read-tree", tree], + env: commitEnv, + }); + for (const { from, to } of switches) { + const changes = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "-c", + "core.quotePath=true", + "diff-tree", + "-r", + "--no-renames", + from, + to, + ], + maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + outputMode: "error", + }); + // A switch succeeds only where the files it replaces were unmodified, so each + // changed path takes the destination's version. Mode 000000 drops the path. + const entries = changes.stdout + .split("\n") + // A switch leaves a submodule's own checkout where it was. + .filter((line) => line !== "" && !/^:(?:160000 |[0-7]{6} 160000 )/.test(line)) + .map((line) => + line.replace( + /^:[0-7]{6} ([0-7]{6}) [0-9a-f]+ ([0-9a-f]+) [A-Z]\t/, + "$1 $2\t", + ), + ); + if (entries.length === 0) continue; + yield* execute({ + operation, + cwd: input.cwd, + args: [ + ...indexConfig, + "-c", + "index.sparse=false", + "update-index", + "--index-info", + ], + stdin: `${entries.join("\n")}\n`, + env: commitEnv, + }); + } + const startTree = (yield* execute({ + operation, + cwd: input.cwd, + args: [...indexConfig, ...durableWrite, "write-tree"], + env: commitEnv, + })).stdout.trim(); + if (startTree === "" || startTree === tree) return null; + const startCommit = yield* execute({ + operation, + cwd: input.cwd, + args: [ + ...durableWrite, + "commit-tree", + startTree, + "-p", + landedOn, + "-p", + commit, + "-m", + `t3 checkpoint turn start ref=${input.checkpointRef}`, + ], + // listAuthoredPaths reads commits newer than the start as the turn's own. + env: { ...commitEnv, GIT_COMMITTER_DATE: `${capturedAt} +0000` }, + }); + return startCommit.stdout.trim() || null; + }).pipe( + Effect.catch((cause) => + Effect.logWarning("checkpoint branch switch turn start failed", { + cwd: input.cwd, + checkpointRef: input.checkpointRef, + cause: String(cause), + }).pipe(Effect.as(null)), + ), + ); + + const message = `t3 checkpoint ref=${input.checkpointRef}${ + headMark === undefined ? "" : `\n\n${HEAD_MARK_TRAILER}: ${headMark}` + }`; + // HEAD is the first parent so listAuthoredPaths can see how HEAD moved between checkpoints. const commitTreeResult = yield* execute({ operation, cwd: input.cwd, @@ -1340,7 +1548,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...durableWrite, "commit-tree", treeOid, - ...(headExists ? ["-p", "HEAD"] : []), + ...(headExists ? ["-p", head ?? "HEAD"] : []), + ...(turnStart === null ? [] : ["-p", turnStart]), "-m", message, ], @@ -1390,20 +1599,90 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); // An empty index and checkpoint have nothing for git restore's pathspec to match. if (tracked.stdout.length > 0) { + const sparseConfig = yield* execute({ + operation, + cwd: input.cwd, + args: ["config", "--bool", "core.sparseCheckout"], + allowNonZeroExit: true, + }); + const sparseCheckout = sparseConfig.stdout.trim() === "true"; + const restore = [...longPaths, "restore", "--source", commitOid, "--worktree", "--staged"]; + // restore leaves skip-worktree paths alone. Outside a sparse checkout every such bit was + // set by hand and capture recorded the file, so override them all. yield* execute({ operation, cwd: input.cwd, - args: [ - ...longPaths, - "restore", - "--source", - commitOid, - "--worktree", - "--staged", - "--", - ".", - ], + args: [...restore, ...(sparseCheckout ? [] : ["--ignore-skip-worktree-bits"]), "--", "."], }); + if (sparseCheckout) { + // Sparse exclusions stay excluded; only skip-worktree paths the rules select are manual. + const manual = yield* Effect.gen(function* () { + const skipped: string[] = []; + let recordStart = true; + let keep = false; + let record: Uint8Array[] = []; + yield* vcsProcess.run({ + operation, + command: "git", + cwd: input.cwd, + args: ["ls-files", "--full-name", "--sparse", "-v", "-z", "--", "."], + maxOutputBytes: 4_096, + outputMode: "truncate", + // The index can be huge; keep only the "S "/"s " records of the NUL-delimited list. + onStdoutChunk: (chunk) => { + let start = 0; + while (start < chunk.length) { + const end = chunk.indexOf(0, start); + if (recordStart) { + keep = chunk[start] === 83 || chunk[start] === 115; + record = []; + } + if (keep) { + record.push(Buffer.from(chunk.subarray(start, end === -1 ? undefined : end))); + if (end !== -1) { + const entry = Buffer.concat(record).subarray(2); + record = []; + // Sparse directory entries are exclusions; stdin cannot carry non-UTF-8. + if (entry.at(-1) !== 47 && NodeBuffer.isUtf8(entry)) + skipped.push(entry.toString("utf8")); + } + } + recordStart = end !== -1; + if (!recordStart) break; + start = end + 1; + } + }, + }); + if (skipped.length === 0) return skipped; + const stdin = skipped.join("\0") + "\0"; + // check-rules echoes a subset of its input, so the input size bounds its output. + const rules = yield* execute({ + operation, + cwd: input.cwd, + args: ["sparse-checkout", "check-rules", "-z"], + stdin, + maxOutputBytes: Buffer.byteLength(stdin), + outputMode: "error", + }); + return rules.stdout.split("\0").filter((entry) => entry.length > 0); + }).pipe( + // A Git without ls-files --sparse or check-rules keeps the plain restore's result. + Effect.orElseSucceed((): string[] => []), + ); + if (manual.length > 0) { + yield* execute({ + operation, + cwd: input.cwd, + args: [ + ...restore, + "--ignore-skip-worktree-bits", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + stdin: manual.map((entry) => `:(top,literal)${entry}\0`).join(""), + }); + } + } } // Restoring away the last tracked file can remove a nested workspace directory. const recreateWorkspace = fileSystem.makeDirectory(input.cwd, { recursive: true }).pipe( @@ -1501,15 +1780,13 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "checkpoint.fallback_from_to_head": input.fallbackFromToHead, }); - let fromRevision: string = input.fromCheckpointRef; + let fromRevision = yield* resolveTurnStart(input); if (input.fallbackFromToHead === true) { const resolvedFromCommit = yield* resolveCheckpointCommit( input.cwd, input.fromCheckpointRef, ); - if (resolvedFromCommit) { - fromRevision = resolvedFromCommit; - } else { + if (!resolvedFromCommit) { const headCommit = yield* resolveHeadCommit(input.cwd); if (!headCommit) { return yield* new VcsProcessExitError({ @@ -1568,25 +1845,36 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) { const operation = "GitVcsDriver.checkpoints.listAuthoredPaths"; + const turnStart = yield* resolveTurnStart(input); const heads = yield* execute({ operation, cwd: input.cwd, args: [ "log", "--no-walk=unsorted", - "--format=%P %ct", - `${input.fromCheckpointRef}^{commit}`, + "--format=%ct %P", + `${turnStart}^{commit}`, `${input.toCheckpointRef}^{commit}`, ], }); const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n"); - const [startHead = "", capturedAt = ""] = fromLine.split(" "); - const [endHead = ""] = toLine.split(" "); + const [capturedAt = "", startHead = ""] = fromLine.split(" "); + const [, endHead = ""] = toLine.split(" "); if (startHead === "" || endHead === "" || startHead === endHead) { return null; } - const listPaths = (args: ReadonlyArray) => + const listLines = (args: ReadonlyArray, stdin?: string) => + execute({ + operation, + cwd: input.cwd, + // log.showSignature prints verification text into the output. + args: ["-c", "log.showSignature=false", ...args], + ...(stdin === undefined ? {} : { stdin }), + maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + outputMode: "error", + }).pipe(Effect.map((result) => result.stdout.split("\n").filter((line) => line !== ""))); + const listPaths = (args: ReadonlyArray, stdin?: string) => execute({ operation, cwd: input.cwd, @@ -1602,26 +1890,73 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--no-renames", "--no-ext-diff", ], + ...(stdin === undefined ? {} : { stdin }), maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, outputMode: "error", }).pipe(Effect.map((result) => result.stdout.split("\0"))); + + // A commit is an import when a fetch delivered it since the previous checkpoint and no + // reflog in any worktree shows this repository creating it. --all covers every worktree's + // HEAD, and with --walk-reflogs --since compares each entry's own time. + const listImportedCommits = Effect.gen(function* () { + const entries = (yield* listLines([ + "log", + "--walk-reflogs", + "--all", + `--since=@${capturedAt}`, + "--format=%H %gD %gs", + ])).map((line) => { + const [commit = "", selector = "", ...subject] = line.split(" "); + return { + commit, + fetched: selector.startsWith("refs/remotes/"), + subject: subject.join(" "), + }; + }); + // A push records commits this repository already had. + const fetchedTips = new Set( + entries + .filter((entry) => entry.fetched && entry.subject !== "update by push") + .map((entry) => entry.commit), + ); + if (fetchedTips.size === 0) return fetchedTips; + // Fetching another branch can deliver a commit that was made here and pushed. + const createdHere = new Set( + entries + .filter((entry) => !entry.fetched && !REF_MOVE_REFLOG_SUBJECT.test(entry.subject)) + .map((entry) => entry.commit), + ); + const fetched = yield* listLines( + ["rev-list", `--since=@${capturedAt}`, "--stdin"], + `${[...fetchedTips, `^${startHead}`].join("\n")}\n`, + ); + return new Set(fetched.filter((commit) => !createdHere.has(commit))); + }); + // Remerge diffs retain merge conflict fixes while excluding clean upstream changes. - const pathLists = yield* Effect.all( + const [recentCommits, ...pathLists] = yield* Effect.all( [ - listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), + listLines(["rev-list", `--since=@${capturedAt}`, `${startHead}..${endHead}`]), + listPaths(["diff", startHead, `${turnStart}^{commit}`]), listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]), listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]), - listPaths([ - "log", - "--format=", - "--diff-merges=remerge", - `--since=@${capturedAt}`, - `${startHead}..${endHead}`, - ]), ], { concurrency: "unbounded" }, ); - return new Set(pathLists.flat().filter((path) => path.length > 0)); + // Without readable reflogs every recent commit stays attributed, as before. + const importedCommits = + recentCommits.length === 0 + ? new Set() + : yield* listImportedCommits.pipe(Effect.orElseSucceed(() => new Set())); + const ownCommits = recentCommits.filter((commit) => !importedCommits.has(commit)); + const ownCommitPaths = + ownCommits.length === 0 + ? [] + : yield* listPaths( + ["log", "--no-walk=unsorted", "--stdin", "--format=", "--diff-merges=remerge"], + `${ownCommits.join("\n")}\n`, + ); + return new Set([...pathLists.flat(), ...ownCommitPaths].filter((path) => path.length > 0)); }), deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d46666cb468d..060beadad39f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -722,6 +722,26 @@ it.effect("reads a branch whose name starts with a parenthesis as attached", () ).pipe(Effect.provide(layerTest)), ); +it.effect("follows a checkout made outside the driver after refs were cached", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "feature/outside"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.listRefs({ cwd }); + + yield* git(cwd, ["switch", "feature/outside"]); + + const refs = (yield* driver.listRefs({ cwd })).refs; + const previous = refs.find((ref) => ref.name === initialBranch); + assert.isTrue(refs.find((ref) => ref.name === "feature/outside")?.current); + assert.isFalse(previous?.current); + assert.isNull(previous?.worktreePath ?? null); + }), + ).pipe(Effect.provide(layerTest)), +); + it.effect.each(["on a branch", "detached", "unborn"] as const)( "reads only the repository root for review requests that ignore the branch when %s", (state) => @@ -4323,6 +4343,39 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { insertions: 1, deletions: 1, }); + const preview = yield* driver.getReviewDiffPreview({ cwd, metadataOnly: true }); + assert.deepStrictEqual( + preview.sources.find((source) => source.kind === "working-tree")?.files, + [ + { path: "asset.data", previousPath: null, additions: 0, deletions: 1 }, + { path: "ordinary.txt", previousPath: null, additions: 1, deletions: 0 }, + ], + ); + }), + ); + + it.effect("keeps an oversized untracked file out of the review diff", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + yield* writeTextFile(cwd, "README.md", "# tracked change\n"); + yield* writeTextFile(cwd, "ordinary.txt", "new\n"); + yield* writeTextFile(cwd, "large.data", ""); + // Sparse: the logical size is over the budget without allocating it. + yield* fileSystem.truncate(pathService.join(cwd, "large.data"), 257 * 1024 * 1024); + + const preview = yield* driver.getReviewDiffPreview({ cwd, metadataOnly: true }); + + const source = preview.sources.find((source) => source.kind === "working-tree")!; + assert.isUndefined(source.files); + assert.isUndefined(source.patchesDeferred); + assert.isTrue(source.truncated); + assert.include(source.diff, "+# tracked change"); + assert.include(source.diff, "b/ordinary.txt"); + assert.notInclude(source.diff, "large.data"); }), ); @@ -4637,6 +4690,28 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("does not report a default branch the remote deleted after the clone", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-origin-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["branch", "-M", "development"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "development", "development:main"]); + yield* git(cwd, ["remote", "set-head", "origin", "development"]); + yield* git(remote, ["--git-dir=.", "symbolic-ref", "HEAD", "refs/heads/main"]); + yield* git(cwd, ["push", "origin", "--delete", "development"]); + yield* git(cwd, ["fetch", "--prune", "origin"]); + + const driver = yield* GitVcsDriver.GitVcsDriver; + const defaultBranch = yield* driver.resolveDefaultBranchName(cwd, "origin"); + + assert.notEqual(defaultBranch, "development"); + assert.isNull(yield* driver.resolveWorktreeBaseRemote({ cwd, refName: "origin/HEAD" })); + }), + ); + it.effect.each(["checkout", "bare-backed worktree"] as const)( "uses the only remote's HEAD for default-branch detection without origin in a %s", (layout) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 32042a0ff5e5..2a0c6772d8d2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -81,8 +81,8 @@ const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; // Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap. const REVIEW_UNTRACKED_MAX_FILES = 5_000; -// git diff --numstat reads every untracked byte (~5 ms/MB), so status totals skip larger untracked input. -const UNTRACKED_TOTALS_MAX_BYTES = 256 * 1024 * 1024; +// git diff reads every untracked byte (~5 ms/MB), so larger untracked input stays out of the review index. +const REVIEW_UNTRACKED_MAX_BYTES = 256 * 1024 * 1024; // The listing walks the whole untracked tree before the cap applies, so a folder whose listing // failed (too many files, output limit, timeout) is not listed again on every status poll. const UNTRACKED_TOTALS_RETRY_MIN_MS = 60_000; @@ -551,7 +551,7 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): return null; } const refName = trimmed.slice(prefix.length).trim(); - return refName.length > 0 ? refName : null; + return refName.length > 0 && refName !== "HEAD" ? refName : null; } function isMissingGitCwdError(error: GitCommandError): boolean { @@ -1495,23 +1495,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; }); + const readCurrentBranch = (operation: string, cwd: string) => + executeGit(operation, cwd, ["symbolic-ref", "--quiet", "HEAD"], { + timeoutMs: 5_000, + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => (result.exitCode === 0 ? branchNameFromHeadRef(result.stdout) : null)), + ); + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( cwd: string, ) { const gitCommonDir = yield* readGitCommonDir(cwd); if (gitCommonDir === null) return null; - const [worktreeRoot, currentBranchResult] = yield* Effect.all( + const [worktreeRoot, currentBranch] = yield* Effect.all( [ readWorktreeRoot(cwd), - executeGit( - "GitVcsDriver.resolveRepositoryPaths.currentBranch", - cwd, - ["symbolic-ref", "--quiet", "HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), + readCurrentBranch("GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd), ], { concurrency: 2 }, ); @@ -1523,8 +1523,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""), Effect.orElseSucceed(() => undefined), ); - const currentBranch = - currentBranchResult.exitCode === 0 ? branchNameFromHeadRef(currentBranchResult.stdout) : null; return { gitCommonDir, @@ -1721,7 +1719,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGit( "GitVcsDriver.resolveDefaultBranchName", cwd, - [...gitDirArgs, "symbolic-ref", `refs/remotes/${remoteName}/HEAD`], + // Unlike `symbolic-ref`, this fails when the remote deleted the branch HEAD points at. + [ + ...gitDirArgs, + "rev-parse", + "--verify", + "--quiet", + "--symbolic-full-name", + `refs/remotes/${remoteName}/HEAD`, + ], { allowNonZeroExit: true }, ).pipe( Effect.map((result) => { @@ -3242,7 +3248,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const prepareReviewIndex = Effect.fn("prepareReviewIndex")(function* ( cwd: string, untrackedPaths: ReadonlyArray, - maxBytes?: number, + allowPartial: boolean, ) { const [stagedDeletionsStdout, indexValue] = yield* Effect.all( [ @@ -3270,21 +3276,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { concurrency: 2 }, ); const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean)); - const pathsToAdd = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath)); - if (pathsToAdd.length === 0) return undefined; - if (maxBytes !== undefined) { - const sizes = yield* Effect.forEach( - pathsToAdd, - (relativePath) => - fileSystem.lstat(path.resolve(cwd, relativePath)).pipe( - Effect.map((info) => (info.type === "File" ? Number(info.size) : 0)), - Effect.orElseSucceed(() => 0), - ), - { concurrency: 16 }, - ); - // null: over the byte budget, so the caller reports totals as unavailable. - if (sizes.reduce((total, size) => total + size, 0) > maxBytes) return null; + // Staged deletions never enter the index, so their worktree copies must not spend the budget. + const candidates = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath)); + const sizes = yield* Effect.forEach( + candidates, + (relativePath) => + fileSystem.lstat(path.resolve(cwd, relativePath)).pipe( + Effect.map((info) => (info.type === "File" ? Number(info.size) : 0)), + Effect.orElseSucceed(() => 0), + Effect.map((size) => ({ relativePath, size })), + ), + { concurrency: 16 }, + ); + // Smallest first, so which files fit does not depend on how their names sort. + const pathsToAdd: string[] = []; + let bytes = 0; + for (const { relativePath, size } of sizes.toSorted((left, right) => left.size - right.size)) { + bytes += size; + if (bytes > REVIEW_UNTRACKED_MAX_BYTES) break; + pathsToAdd.push(relativePath); } + const omitted = pathsToAdd.length < candidates.length; + if (pathsToAdd.length === 0 || (omitted && !allowPartial)) return { env: undefined, omitted }; const indexPath = path.isAbsolute(indexValue.trim()) ? indexValue.trim() @@ -3337,7 +3350,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { env, stdin: `${pathsToAdd.join("\0")}\0` }, ); - return env; + return { env, omitted }; }); const readEmptyTreeHash = Effect.fn("readEmptyTreeHash")(function* (cwd: string) { @@ -3353,8 +3366,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const prepareUntrackedReviewIndexOrReason = Effect.fn("prepareUntrackedReviewIndex")(function* ( cwd: string, pathArgs: ReadonlyArray, - onlyPath?: string, - maxBytes?: number, + onlyPath: string | undefined, + allowPartial: boolean, ) { const untracked = yield* executeGit( "GitVcsDriver.review.listUntracked", @@ -3373,10 +3386,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (candidate) => onlyPath === undefined || candidate === onlyPath || candidate === `${onlyPath}/`, ); - if (paths.length === 0) return { env: undefined }; + if (paths.length === 0) return { env: undefined, omitted: false }; if (paths.length > REVIEW_UNTRACKED_MAX_FILES) return { unavailable: "Too many untracked files to count." }; - const env = yield* prepareReviewIndex(cwd, paths, maxBytes).pipe( + const index = yield* prepareReviewIndex(cwd, paths, allowPartial).pipe( Effect.catchTags({ PlatformError: (cause) => Effect.fail( @@ -3390,15 +3403,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), }), ); - if (env === null) return { unavailable: "Untracked files are too large to count." }; - return { env }; + if (index.omitted && !allowPartial) + return { unavailable: "Untracked files are too large to count." }; + return index; }); const prepareUntrackedReviewIndex = ( cwd: string, pathArgs: ReadonlyArray, onlyPath?: string, ) => - prepareUntrackedReviewIndexOrReason(cwd, pathArgs, onlyPath).pipe( + prepareUntrackedReviewIndexOrReason(cwd, pathArgs, onlyPath, true).pipe( Effect.map((result) => ("unavailable" in result ? null : result)), ); @@ -3490,12 +3504,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } // Empty status can mean untracked files are hidden; retain the full scan. - const untracked = yield* prepareUntrackedReviewIndexOrReason( - cwd, - pathspecs, - undefined, - UNTRACKED_TOTALS_MAX_BYTES, - ); + const untracked = yield* prepareUntrackedReviewIndexOrReason(cwd, pathspecs, undefined, false); if ("unavailable" in untracked) { const count = remembered?.roots === rootsKey ? remembered.count + 1 : 1; untrackedTotalsFailures.delete(cwd); @@ -3846,9 +3855,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () { const untracked = input.trackedOnly - ? { env: undefined } + ? { env: undefined, omitted: false } : yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path); - const metadataOnly = input.metadataOnly === true && !input.file && untracked !== null; + // Untracked files left out make the file list partial, so the bounded patch is sent instead. + const complete = untracked !== null && !untracked.omitted; + const metadataOnly = input.metadataOnly === true && !input.file && complete; const [dirty, base] = review.mergeBase === dirtyRef ? yield* readTrackedDiff(dirtyRef, untracked?.env, metadataOnly).pipe( @@ -3861,7 +3872,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: 2 }, ); - if (untracked !== null) return [dirty, base] as const; + if (complete) return [dirty, base] as const; const incomplete = (ref: string | null, result: typeof dirty) => ref === null ? result : { ...result, files: undefined, stdoutTruncated: true }; return [incomplete(dirtyRef, dirty), incomplete(review.mergeBase, base)] as const; @@ -4217,7 +4228,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGit( "GitVcsDriver.listRefs.defaultRef", fetchCwd, - [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"], + [ + ...gitDirArgs, + "rev-parse", + "--verify", + "--quiet", + "--symbolic-full-name", + "refs/remotes/origin/HEAD", + ], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -4245,7 +4263,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const defaultBranch = defaultRefResult.exitCode === 0 - ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + ? parseDefaultBranchFromRemoteHeadRef(defaultRefResult.stdout, "origin") : null; const parsedWorktreeEntries = worktreeListResult.exitCode === 0 @@ -4448,22 +4466,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - const snapshot = yield* resolveListRefsSnapshot( - repositoryPaths.gitCommonDir, - input.refresh === true, + // Both caches outlive a checkout made outside T3 Code, so this checkout's branch is read + // on every call and replaces whatever the cached snapshot claims for it. + const [snapshot, currentBranch] = yield* Effect.all( + [ + resolveListRefsSnapshot(repositoryPaths.gitCommonDir, input.refresh === true), + readCurrentBranch("GitVcsDriver.listRefs.currentBranch", input.cwd).pipe( + Effect.orElseSucceed(() => repositoryPaths.currentBranch), + ), + ], + { concurrency: 2 }, ); - const hasCurrentWorktreeBranch = - repositoryPaths.worktreeRoot !== null && - snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot); - const localBranches = snapshot.localBranches.map((ref) => ({ - ...ref, - worktreePath: - ref.worktreePath ?? - (ref.name === repositoryPaths.currentBranch ? repositoryPaths.worktreeRoot : null), - current: hasCurrentWorktreeBranch - ? ref.worktreePath === repositoryPaths.worktreeRoot - : ref.name === repositoryPaths.currentBranch, - })); + const { worktreeRoot } = repositoryPaths; + const localBranches = snapshot.localBranches.map((ref) => { + const current = ref.name === currentBranch; + return { + ...ref, + worktreePath: + worktreeRoot === null + ? ref.worktreePath + : current + ? worktreeRoot + : ref.worktreePath === worktreeRoot + ? null + : ref.worktreePath, + current, + }; + }); const combinedBranches = input.includeMatchingRemoteRefs ? [...localBranches, ...snapshot.remoteBranches] : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]); @@ -5036,7 +5065,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* remoteName: "origin", branchName: input.refName, }; - return remoteNames.includes(remoteName) ? { remoteName, branchName } : null; + // `/HEAD` is the remote's default-branch pointer, not a branch it can serve. + return remoteNames.includes(remoteName) && branchName !== "HEAD" + ? { remoteName, branchName } + : null; }); const fetchWorktreeBaseCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchWorktreeBaseCommit"] = @@ -5578,6 +5610,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* withListRefsInvalidation(input.cwd, switchRef(input)); }), initRepo: initRepoWithListRefsInvalidation, + invalidateRefs: Effect.fn("GitVcsDriver.invalidateRefs")(function* (cwd) { + // First, so a cached "not a repository" cannot end the ref invalidation early. + const cacheKey = normalizeRepositoryPathsCacheKey(cwd); + yield* Cache.invalidate(repositoryPathsRefreshCache, cacheKey); + yield* Cache.invalidate(repositoryPathsCache, cacheKey); + return yield* withListRefsInvalidation(cwd, Effect.void); + }), listLocalBranchNames, listWorktreePaths, }); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index b382922c5ab2..383db4302543 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -14,6 +14,8 @@ import * as VcsProcess from "./VcsProcess.ts"; export interface VcsCaptureCheckpointInput { readonly cwd: string; readonly checkpointRef: CheckpointRef; + /** The checkpoint this one follows, so a turn that switched branches starts where it landed. */ + readonly previousCheckpointRef?: CheckpointRef; } export interface VcsRestoreCheckpointInput { diff --git a/apps/server/src/workspace/IgnorePatterns.test.ts b/apps/server/src/workspace/IgnorePatterns.test.ts new file mode 100644 index 000000000000..f10345566eca --- /dev/null +++ b/apps/server/src/workspace/IgnorePatterns.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { parseIgnoreFile } from "./IgnorePatterns.ts"; + +const decide = (contents: string, path: string, isFolder = false) => + parseIgnoreFile(contents).decide(path, isFolder); + +describe("parseIgnoreFile", () => { + it("lets the last matching pattern decide, including negations", () => { + expect(decide("*keep*\n!*keep*\n", "sub/keep-link.ts")).toBe("included"); + expect(decide("!*keep*\n*keep*\n", "sub/keep-link.ts")).toBe("ignored"); + expect(decide("# comment\n\nother\n", "keep.ts")).toBeUndefined(); + }); + + it("anchors patterns that contain a slash and matches bare names at any depth", () => { + expect(decide("link", "a/b/link")).toBe("ignored"); + expect(decide("/link", "a/link")).toBeUndefined(); + expect(decide("/link", "link")).toBe("ignored"); + expect(decide("a/link", "x/a/link")).toBeUndefined(); + expect(decide("a/*.ts", "a/b/c.ts")).toBeUndefined(); + expect(decide("link", "blink")).toBeUndefined(); + expect(decide("link", "a/blink")).toBeUndefined(); + }); + + it("supports globstars, classes, escapes, and folder-only patterns", () => { + expect(decide("**/gen/*.ts", "gen/a.ts")).toBe("ignored"); + expect(decide("a/**/b", "a/x/y/b")).toBe("ignored"); + expect(decide("a/**/b", "a/b")).toBe("ignored"); + expect(decide("foo/**/bar", "foo/xbar")).toBeUndefined(); + expect(decide("foo/**/bar", "foo/x/ybar")).toBeUndefined(); + expect(decide("out/**", "out/x/y")).toBe("ignored"); + expect(decide("link-[0-9]", "link-7")).toBe("ignored"); + expect(decide("link-[!0-9]", "link-7")).toBeUndefined(); + expect(decide("link-?", "link-/")).toBeUndefined(); + expect(decide("\\!bang\n\\#hash\nstar\\*", "!bang")).toBe("ignored"); + expect(decide("\\!bang\n\\#hash\nstar\\*", "#hash")).toBe("ignored"); + expect(decide("\\!bang\n\\#hash\nstar\\*", "starx")).toBeUndefined(); + expect(decide("dist/", "dist")).toBeUndefined(); + expect(decide("dist/", "dist", true)).toBe("ignored"); + }); + + it("matches adversarial patterns in linear time", () => { + // A backtracking regex takes seconds to reject this pair; the vitest timeout catches that. + const pattern = `${"a*".repeat(20)}b\n`; + expect(decide(pattern, `${"a".repeat(200)}c`)).toBeUndefined(); + expect(decide(pattern.repeat(50), `${"a".repeat(1_000)}c`)).toBeUndefined(); + expect(decide(pattern, `${"a".repeat(200)}b`)).toBe("ignored"); + }); + + it("parses unclosed classes in linear time", () => { + // Rescanning the rest of the line for each `[` stalls for over a minute here; linear takes ms. + expect(decide("[".repeat(30_000), "[".repeat(30_000))).toBe("ignored"); + expect(decide(`${"[".repeat(30_000)}]`, "x")).toBeUndefined(); + expect(decide("[a][", "a[")).toBe("ignored"); + }); +}); diff --git a/apps/server/src/workspace/IgnorePatterns.ts b/apps/server/src/workspace/IgnorePatterns.ts new file mode 100644 index 000000000000..1180b02316ef --- /dev/null +++ b/apps/server/src/workspace/IgnorePatterns.ts @@ -0,0 +1,170 @@ +/** + * Gitignore-syntax matching for `.ignore` files, which git cannot evaluate for us. Patterns come + * from the repository, so each runs as an NFA over the path (O(pattern × path)): a backtracking + * regex stalls for seconds on `a*a*a*…b`. POSIX `[:class:]` names are not supported. + */ + +type Token = + | { readonly type: "char"; readonly char: string } + | { readonly type: "one" } + | { readonly type: "class"; readonly negated: boolean; readonly ranges: ReadonlyArray } + | { readonly type: "star" } + | { readonly type: "any" } + /** A leading or inner `**` segment: nothing, or anything ending in `/`. */ + | { readonly type: "folders" }; + +interface Pattern { + readonly tokens: ReadonlyArray; + readonly negated: boolean; + readonly folderOnly: boolean; +} + +export type IgnoreDecision = "ignored" | "included"; + +function parseClass(chars: ReadonlyArray, start: number) { + let index = start + 1; + const negated = chars[index] === "!" || chars[index] === "^"; + if (negated) index += 1; + const ranges: string[] = []; + for (let first = true; index < chars.length; first = false) { + let char = chars[index]!; + if (char === "]" && !first) + return { token: { type: "class", negated, ranges } as const, end: index }; + if (char === "\\" && index + 1 < chars.length) char = chars[++index]!; + if (chars[index + 1] === "-" && index + 2 < chars.length && chars[index + 2] !== "]") { + let last = chars[index + 2]!; + index += 2; + if (last === "\\" && index + 1 < chars.length) last = chars[++index]!; + ranges.push(char, last); + } else { + ranges.push(char, char); + } + index += 1; + } + return undefined; +} + +function parseLine(rawLine: string): Pattern | undefined { + let line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + // Trailing spaces are dropped unless escaped. + while (line.endsWith(" ") && !line.endsWith("\\ ")) line = line.slice(0, -1); + if (line === "" || line.startsWith("#")) return undefined; + const negated = line.startsWith("!"); + if (negated) line = line.slice(1); + else if (line.startsWith("\\!") || line.startsWith("\\#")) line = line.slice(1); + const folderOnly = line.endsWith("/") && !line.endsWith("\\/"); + if (folderOnly) line = line.slice(0, -1); + if (line === "") return undefined; + const anchored = line.slice(0, -1).includes("/"); + if (line.startsWith("/")) line = line.slice(1); + + const chars = Array.from(line); + const tokens: Token[] = anchored ? [] : [{ type: "folders" }]; + // A `[` that never closes leaves every later `[` unclosed too, so one failed scan settles them + // all; rescanning the suffix for each would make `[[[[…` quadratic. + let classesClose = true; + for (let index = 0; index < chars.length; index += 1) { + const char = chars[index]!; + if (char === "*" && chars[index + 1] === "*") { + const atSegmentStart = index === 0 || chars[index - 1] === "/"; + const next = chars[index + 2]; + if (atSegmentStart && next === "/") { + tokens.push({ type: "folders" }); + index += 2; + continue; + } + if (atSegmentStart && next === undefined) { + tokens.push({ type: "any" }); + index += 1; + continue; + } + } + if (char === "*") tokens.push({ type: "star" }); + else if (char === "?") tokens.push({ type: "one" }); + else if (char === "[") { + const parsed = classesClose ? parseClass(chars, index) : undefined; + if (!parsed) classesClose = false; + if (parsed) { + tokens.push(parsed.token); + index = parsed.end; + } else tokens.push({ type: "char", char }); + } else if (char === "\\" && index + 1 < chars.length) { + tokens.push({ type: "char", char: chars[++index]! }); + } else tokens.push({ type: "char", char }); + } + return { tokens, negated, folderOnly }; +} + +/** + * States are token positions. A `folders` token that has consumed part of a segment is `~position` + * instead: it may only finish after the next `/`, so an inner globstar never matches `xbar` as `bar`. + */ +function matches(tokens: ReadonlyArray, path: ReadonlyArray): boolean { + let states = new Set(); + const add = (set: Set, position: number) => { + // Wildcards also match nothing, so reaching one reaches the token after it. + for (; position <= tokens.length && !set.has(position); position += 1) { + set.add(position); + const type = tokens[position]?.type; + if (type !== "star" && type !== "any" && type !== "folders") return; + } + }; + add(states, 0); + for (const char of path) { + const next = new Set(); + for (const state of states) { + const position = state < 0 ? ~state : state; + const token = tokens[position]; + if (!token) continue; + switch (token.type) { + case "char": + if (token.char === char) add(next, position + 1); + break; + case "one": + if (char !== "/") add(next, position + 1); + break; + case "class": { + let inRange = false; + for (let index = 0; index < token.ranges.length && !inRange; index += 2) { + inRange = token.ranges[index]! <= char && char <= token.ranges[index + 1]!; + } + if (char !== "/" && inRange !== token.negated) add(next, position + 1); + break; + } + case "star": + if (char !== "/") add(next, position); + break; + case "any": + add(next, position); + break; + case "folders": + if (char === "/") add(next, position); + else next.add(~position); + break; + } + } + if (next.size === 0) return false; + states = next; + } + return states.has(tokens.length); +} + +export interface IgnoreFile { + /** The last pattern that matches decides; `undefined` when none does. */ + readonly decide: (relativePath: string, isFolder: boolean) => IgnoreDecision | undefined; +} + +export function parseIgnoreFile(contents: string): IgnoreFile { + const patterns = contents.split("\n").flatMap((line) => parseLine(line) ?? []); + return { + decide: (relativePath, isFolder) => { + const path = Array.from(relativePath); + for (let index = patterns.length - 1; index >= 0; index -= 1) { + const pattern = patterns[index]!; + if (pattern.folderOnly && !isFolder) continue; + if (matches(pattern.tokens, path)) return pattern.negated ? "included" : "ignored"; + } + return undefined; + }, + }; +} diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 348b5f877ac2..521e12777998 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -433,6 +433,20 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); }), ); + + it.effect("finds ignored files that sort past the listing limit", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".gitignore", "*.secret\n"); + for (let index = 1; index <= 205; index += 1) { + yield* writeTextFile(cwd, `file-${String(index).padStart(3, "0")}.secret`, "s"); + } + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const listed = yield* workspaceEntries.listIgnoredFiles({ cwd, query: "file-205" }); + expect(listed.files).toContainEqual({ path: "file-205.secret", byteLength: 1 }); + }), + ); }); describe("search", () => { @@ -532,6 +546,133 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("indexes symlinked files and directories inside the workspace", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-" }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "src/index.ts", "export {};"); + yield* fileSystem.symlink("src", path.join(cwd, "src-link")); + yield* fileSystem.symlink("src/index.ts", path.join(cwd, "index-link.ts")); + const symlinkEntries = [ + { kind: "directory", path: "src-link" }, + { kind: "file", path: "index-link.ts" }, + ]; + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searched = yield* searchWorkspaceEntries({ cwd, query: "link", limit: 100 }); + const listed = yield* workspaceEntries.list({ cwd }); + + for (const entry of symlinkEntries) { + expect(searched.entries).toContainEqual(expect.objectContaining(entry)); + expect(listed.entries).toContainEqual(expect.objectContaining(entry)); + } + }), + ); + it.effect("skips symlinks that ignore rules hide or that leave the workspace", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-", git: true }); + const outside = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-outside-" }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "src/index.ts", "export {};"); + yield* writeTextFile(cwd, ".ignore", "custom-link\n"); + yield* fileSystem.symlink("src", path.join(cwd, "custom-link")); + yield* fileSystem.symlink("src/index.ts", path.join(cwd, "tracked-link.ts")); + yield* fileSystem.symlink("src", path.join(cwd, "ignored-link")); + yield* fileSystem.symlink("src", path.join(cwd, "kept-link")); + yield* fileSystem.symlink(outside, path.join(cwd, "outside-link")); + yield* git(cwd, ["add", "tracked-link.ts"]); + yield* writeTextFile(cwd, ".gitignore", "tracked-link.ts\nignored-link\n"); + // A submodule path makes `git check-ignore` refuse the whole batch without --no-index. + const submodule = path.join(cwd, "vendor/sub"); + yield* writeTextFile(submodule, "a.ts", "export {};"); + yield* git(submodule, ["init"]); + yield* git(submodule, ["add", "a.ts"]); + yield* git(submodule, [ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "commit", + "-m", + "init", + ]); + yield* git(cwd, ["add", "vendor/sub"]); + yield* fileSystem.symlink("a.ts", path.join(submodule, "sublink.ts")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searched = yield* searchWorkspaceEntries({ cwd, query: "link", limit: 100 }); + const listed = yield* workspaceEntries.list({ cwd }); + + for (const entries of [searched.entries, listed.entries]) { + const paths = entries.map((entry) => entry.path); + expect(paths).toContain("kept-link"); + expect(paths).toContain("vendor/sub/sublink.ts"); + expect(paths).not.toContain("custom-link"); + expect(paths).not.toContain("tracked-link.ts"); + expect(paths).not.toContain("ignored-link"); + expect(paths).not.toContain("outside-link"); + } + }), + ); + it.effect("lets the nearest .ignore rule decide a symlink, and git's rules still hide it", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-", git: true }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".gitignore", "*keep*\n"); + yield* writeTextFile(cwd, ".ignore", "!*keep*\n*hide*\n"); + yield* writeTextFile(cwd, "sub/.ignore", "!*hide*\n"); + yield* writeTextFile(cwd, "keep-real.ts", "export {};"); + yield* writeTextFile(cwd, "sub/hide-real.ts", "export {};"); + yield* fileSystem.symlink("keep-real.ts", path.join(cwd, "keep-link.ts")); + yield* fileSystem.symlink("hide-real.ts", path.join(cwd, "sub/hide-link.ts")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searched = yield* searchWorkspaceEntries({ cwd, query: "link", limit: 100 }); + const listed = yield* workspaceEntries.list({ cwd }); + + for (const entries of [searched.entries, listed.entries]) { + const paths = entries.map((entry) => entry.path); + expect(paths).toContain("sub/hide-link.ts"); + expect(paths).not.toContain("keep-link.ts"); + } + // The real files are shown or hidden by the same rules. + const listedPaths = listed.entries.map((entry) => entry.path); + expect(listedPaths).toContain("sub/hide-real.ts"); + expect(listedPaths).not.toContain("keep-real.ts"); + }), + ); + it.effect("matches .ignore rules against whole names and treats folder links as links", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-symlinks-", git: true }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "src/index.ts", "export {};"); + yield* writeTextFile(cwd, ".gitignore", "blink\n"); + // `!link` must not re-include `blink`, and `folder-link/` does not apply to a link. + yield* writeTextFile(cwd, ".ignore", "!link\nfolder-link/\n"); + yield* fileSystem.symlink("src", path.join(cwd, "blink")); + yield* fileSystem.symlink("src", path.join(cwd, "folder-link")); + yield* writeTextFile(cwd, "sub/blink", "regular"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searched = yield* searchWorkspaceEntries({ cwd, query: "link", limit: 100 }); + const listed = yield* workspaceEntries.list({ cwd }); + + for (const entries of [searched.entries, listed.entries]) { + const paths = entries.map((entry) => entry.path); + expect(paths).not.toContain("blink"); + expect(paths).not.toContain("sub/blink"); + expect(paths).toContain("folder-link"); + } + }), + ); it.effect("finds a path inside a gitignored nested git worktree only by its exact path", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-worktree-", git: true }); @@ -822,10 +963,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { if (created.ok) { const finder = created.value; finders += 1; - // The synchronous rescan waits on fff's picker lock on the event loop. + // The synchronous rescan and grep wait on fff's picker lock on the event loop. finder.scanFiles = () => { throw new Error("refresh must not rescan on the JS thread"); }; + finder.grep = () => { + throw new Error("search must not grep on the JS thread"); + }; const scanFilesAsync = finder.scanFilesAsync.bind(finder); finder.scanFilesAsync = () => { rescans += 1; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index cac706f7ca4a..43c8fae4dfca 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -576,9 +576,13 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.orElseSucceed(() => undefined)); if (!result || result.exitCode !== 0) return { files: [], truncated: false }; + // Filter before the cap so files sorting past it stay reachable by search. + const query = input.query?.toLowerCase() ?? ""; const paths = result.stdout .split("\0") - .filter((entry) => entry.length > 0 && !entry.endsWith("/")) + .filter( + (entry) => entry.length > 0 && !entry.endsWith("/") && entry.toLowerCase().includes(query), + ) .toSorted(); const files = yield* Effect.promise(() => Promise.all( diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index dffb0701d68a..3d49b98de567 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -12,6 +12,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; +import { makeFakeWslHost } from "../testUtils/fakeWsl.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as KnownWorkspaces from "./KnownWorkspaces.ts"; @@ -96,6 +97,14 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const makeWslHost = Effect.gen(function* () { + const { mountRoot, underWsl } = yield* makeFakeWslHost; + const service = yield* underWsl( + WorkspaceFileSystem.make.pipe(Effect.provide(layerKnownWorkspacesTest)), + ); + return { mountRoot, service, underWsl }; +}); + it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { describe("getMetadata", () => { it.effect( @@ -181,6 +190,31 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i expect(statted).toEqual([]); }), ); + + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "reports a Windows drive path under WSL as the file it maps to", + () => + Effect.gen(function* () { + const { mountRoot, service, underWsl } = yield* makeWslHost; + yield* writeTextFile(mountRoot, "c/Users/me/Documents/notes.md", "# Notes\n"); + + const { entries } = yield* underWsl( + service.getMetadata({ + paths: [ + "C:\\Users\\me\\Documents\\notes.md", + "C:/Users/me/Documents/notes.md", + "C:/Users/me/Documents/notes\u0000.md", + ], + }), + ); + + expect(entries).toEqual([ + { kind: "file", byteLength: 8 }, + { kind: "file", byteLength: 8 }, + null, + ]); + }), + ); }); describe("readFile", () => { @@ -351,6 +385,24 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "reads a Windows drive path under WSL from where wslpath mounts it", + () => + Effect.gen(function* () { + const { mountRoot, service, underWsl } = yield* makeWslHost; + const cwd = yield* makeTempDir; + yield* writeTextFile(mountRoot, "c/Users/me/Documents/notes.md", "# Notes\n"); + + for (const relativePath of [ + "C:\\Users\\me\\Documents\\notes.md", + "C:/Users/me/Documents/notes.md", + ]) { + const result = yield* underWsl(service.readFile({ cwd, relativePath })); + expect(result).toMatchObject({ contents: "# Notes\n", truncated: false }); + } + }), + ); + it.effect("refuses a network path outside the workspace", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 05409c6616ff..781e79eda80b 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -19,6 +19,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Schema from "effect/Schema"; import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts"; @@ -26,6 +27,7 @@ import * as KnownWorkspaces from "./KnownWorkspaces.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; +import { toWslDrivePath } from "./wslDrivePath.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; @@ -141,9 +143,18 @@ export const make = Effect.gen(function* () { const knownWorkspaces = yield* KnownWorkspaces.KnownWorkspaces; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const isServerInternalPath = yield* makeIsServerInternalPath; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const toHostPath = (requestedPath: string) => + toWslDrivePath(requestedPath).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) { - const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory); + const expandedPath = expandHomePath( + yield* toHostPath(requestedPath), + yield* HostProcess.HomeDirectory, + ); // Chips ask as soon as an agent's message renders, and touching a network // path makes Windows sign in to its host with the user's NTLM credentials. if (!path.isAbsolute(expandedPath) || isNetworkPath(expandedPath)) return null; @@ -230,7 +241,7 @@ export const make = Effect.gen(function* () { const requestedPath = input.relativePath; const hostPath = requestedPath.startsWith("~/") ? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory) - : requestedPath; + : yield* toHostPath(requestedPath); if ( isNetworkPath(hostPath) && !KnownWorkspaces.isWithinRoot(path, path.resolve(hostPath), path.resolve(input.cwd)) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index c01600842f2f..45102e4c1cfe 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -4,14 +4,18 @@ import { type GrepCursor, type GrepOptions, type GrepResult, + type WatchEvent, } from "@ff-labs/fff-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterEach, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -43,7 +47,7 @@ it.effect("filters image searches before applying the result limit", () => ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), fileItem("public/icon.svg"), ]; - const fileSearch = vi.fn(() => ({ + const fileSearch = vi.fn(async () => ({ ok: true as const, value: { items, @@ -55,7 +59,12 @@ it.effect("filters image searches before applying the result limit", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - fileSearch, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), + watch: () => ({ ok: true as const, value: () => undefined }), + fileSearchAsync: fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -89,7 +98,7 @@ it.effect("fetches past a first page of Git-ignored results", () => fileItem("hidden/c.ts"), fileItem("src/a.ts"), ]; - const fileSearch = vi.fn((_query: string, options?: { pageSize?: number }) => ({ + const fileSearch = vi.fn(async (_query: string, options?: { pageSize?: number }) => ({ ok: true as const, value: { items: items.slice(0, options?.pageSize), @@ -101,7 +110,12 @@ it.effect("fetches past a first page of Git-ignored results", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - fileSearch, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), + watch: () => ({ ok: true as const, value: () => undefined }), + fileSearchAsync: fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -123,7 +137,12 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - fileSearch: vi.fn(() => ({ + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), + watch: () => ({ ok: true as const, value: () => undefined }), + fileSearchAsync: vi.fn(async () => ({ ok: true as const, value: { items, scores: [], totalMatched: 1, totalFiles: 1 }, })), @@ -174,7 +193,7 @@ it.effect("continues whole-word searches after a filtered grep page", () => filteredFileCount: 1, nextCursor: cursor, }); - const grep = vi.fn((_query: string, options?: GrepOptions) => + const grep = vi.fn(async (_query: string, options?: GrepOptions) => options?.cursor ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } : { @@ -185,7 +204,12 @@ it.effect("continues whole-word searches after a filtered grep page", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - grep, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), + watch: () => ({ ok: true as const, value: () => undefined }), + grepAsync: grep, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -215,6 +239,249 @@ it.effect("continues whole-word searches after a filtered grep page", () => ).pipe(Effect.provide(testLayer)), ); +function fakeSymlinkFinder( + directories: ReadonlyArray, + options: { + readonly indexed?: ReadonlyArray<{ + readonly path: string; + readonly kind: "file" | "directory"; + }>; + readonly watchFailures?: number; + } = {}, +) { + const watchers: Array<(events: ReadonlyArray) => void> = []; + let watchFailures = options.watchFailures ?? 0; + // fff's pageIndex is an offset into the results, not a page number. + const directorySearch = vi.fn( + async (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => { + const offset = searchOptions?.pageIndex ?? 0; + const items = directories.slice(offset, offset + (searchOptions?.pageSize ?? 100)); + return { + ok: true as const, + value: { + items: items.map((relativePath) => ({ relativePath })), + totalMatched: directories.length, + }, + }; + }, + ); + const indexedItems = (options.indexed ?? []).map((entry) => ({ + type: entry.kind, + item: { relativePath: entry.path, fileName: entry.path.split("/").at(-1) }, + })); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + // fff's isScanning waits on the picker lock on the JS thread. + isScanning: () => { + throw new Error("isScanning must not run on the JS thread"); + }, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), + watch: (callback: (events: ReadonlyArray) => void) => { + if (watchFailures > 0) { + watchFailures -= 1; + throw new Error("File system watcher is not ready"); + } + watchers.push(callback); + return { ok: true as const, value: () => undefined }; + }, + directorySearchAsync: directorySearch, + fileSearchAsync: async () => { + const items = indexedItems.flatMap((entry) => (entry.type === "file" ? [entry.item] : [])); + return { ok: true as const, value: { items, scores: [], totalMatched: items.length } }; + }, + mixedSearchAsync: async () => ({ + ok: true as const, + value: { items: indexedItems, totalMatched: indexedItems.length }, + }), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + return { + directorySearch, + notifyWatchers: (events: ReadonlyArray) => + watchers.forEach((watcher) => watcher(events)), + }; +} + +const listedPaths = (searchIndex: WorkspaceSearchIndex.WorkspaceSearchIndex["Service"]) => + searchIndex.list().pipe(Effect.map((result) => result.entries.map((entry) => entry.path))); + +it.effect("reads symlinks again after the watcher reports a change", () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-watch-" }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-outside-" }); + yield* fileSystem.writeFileString(path.join(cwd, "a.ts"), ""); + yield* fileSystem.symlink("a.ts", path.join(cwd, "old-link.ts")); + const { directorySearch, notifyWatchers } = fakeSymlinkFinder([]); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const concurrent = yield* Effect.all( + Array.from({ length: 8 }, () => listedPaths(searchIndex)), + { concurrency: "unbounded" }, + ); + yield* fileSystem.remove(path.join(cwd, "old-link.ts")); + yield* fileSystem.symlink("a.ts", path.join(cwd, "new-link.ts")); + const beforeNotice = yield* listedPaths(searchIndex); + // Editing a file cannot move a link, so it does not cost a rescan. + notifyWatchers([{ kind: "modified", path: path.join(cwd, "a.ts") }]); + const afterEdit = yield* listedPaths(searchIndex); + notifyWatchers([{ kind: "created", path: path.join(cwd, "new-link.ts") }]); + const afterNotice = yield* listedPaths(searchIndex); + // fff reports a link replaced in place as modified. + yield* fileSystem.remove(path.join(cwd, "new-link.ts")); + yield* fileSystem.symlink(outside, path.join(cwd, "new-link.ts")); + notifyWatchers([{ kind: "modified", path: path.join(cwd, "new-link.ts") }]); + const afterReplace = yield* listedPaths(searchIndex); + + expect(concurrent).toEqual(Array.from({ length: 8 }, () => ["old-link.ts"])); + expect(beforeNotice).toEqual(["old-link.ts"]); + expect(afterEdit).toEqual(["old-link.ts"]); + expect(afterNotice).toEqual(["new-link.ts"]); + expect(afterReplace).toEqual([]); + expect(directorySearch).toHaveBeenCalledTimes(3); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("answers from the last symlink scan while a slow rescan runs", () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-stale-" }); + yield* fileSystem.writeFileString(path.join(cwd, "a.ts"), ""); + yield* fileSystem.symlink("a.ts", path.join(cwd, "old-link.ts")); + const { notifyWatchers } = fakeSymlinkFinder([]); + // Holding git's ignore check open keeps the rescan from finishing. + const gate = yield* Deferred.make(); + let holdIgnoreChecks = false; + const vcsProcess = yield* VcsProcess.VcsProcess; + const searchIndex = yield* WorkspaceSearchIndex.make(cwd).pipe( + Effect.provideService(VcsProcess.VcsProcess, { + ...vcsProcess, + run: (input) => + holdIgnoreChecks && input.operation === "WorkspaceSearchIndex.listGitIgnored" + ? Deferred.await(gate).pipe(Effect.andThen(vcsProcess.run(input))) + : vcsProcess.run(input), + }), + ); + expect(yield* listedPaths(searchIndex)).toEqual(["old-link.ts"]); + + holdIgnoreChecks = true; + yield* fileSystem.symlink("a.ts", path.join(cwd, "new-link.ts")); + notifyWatchers([{ kind: "created", path: path.join(cwd, "new-link.ts") }]); + const duringRescan = yield* Effect.forkChild(listedPaths(searchIndex)); + yield* TestClock.adjust("1 second"); + expect(yield* Fiber.join(duringRescan)).toEqual(["old-link.ts"]); + + yield* Deferred.succeed(gate, undefined); + expect(yield* listedPaths(searchIndex)).toEqual(["new-link.ts", "old-link.ts"]); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("keeps symlinks once the watcher subscribes after starting late", () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-watch-" }); + yield* fileSystem.writeFileString(path.join(cwd, "a.ts"), ""); + yield* fileSystem.symlink("a.ts", path.join(cwd, "link.ts")); + const { directorySearch } = fakeSymlinkFinder([], { watchFailures: 2 }); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const scansAfterRound: number[] = []; + for (let round = 0; round < 5; round += 1) { + expect(yield* listedPaths(searchIndex)).toEqual(["link.ts"]); + expect(yield* listedPaths(searchIndex)).toEqual(["link.ts"]); + scansAfterRound.push(directorySearch.mock.calls.length); + yield* TestClock.adjust("1 minute"); + } + + // An unwatched scan serves reads only briefly; the third has a watcher and is kept. + expect(scansAfterRound).toEqual([1, 2, 3, 3, 3]); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect( + "lists a symlink the watcher indexed once, with the link's kind, and drops rejected ones", + () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-dupe-" }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-outside-" }); + yield* fileSystem.makeDirectory(path.join(cwd, "src")); + yield* fileSystem.symlink("src", path.join(cwd, "src-link")); + // A link the scan rejects stays out even when the watcher indexed it. + yield* fileSystem.symlink(outside, path.join(cwd, "outside-link")); + fakeSymlinkFinder([], { + indexed: [ + { path: "outside-link", kind: "file" }, + { path: "src-link", kind: "file" }, + { path: "link-file.ts", kind: "file" }, + ], + }); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const searched = yield* searchIndex.search("link", 10); + const files = yield* searchIndex.search("link", 1, "file"); + const listed = yield* searchIndex.list(); + + expect(searched.entries).toEqual([ + { path: "src-link", kind: "directory" }, + { path: "link-file.ts", kind: "file" }, + ]); + // The dropped link does not take the only slot. + expect(files).toEqual({ + entries: [{ path: "link-file.ts", kind: "file" }], + truncated: false, + }); + expect(listed.entries).toEqual([ + { path: "link-file.ts", kind: "file" }, + { path: "src-link", kind: "directory" }, + ]); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("finds symlinks in folders past the first page of the index", () => + Effect.scoped( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-symlink-pages-" }); + yield* fileSystem.makeDirectory(path.join(cwd, "late")); + yield* fileSystem.writeFileString(path.join(cwd, "late/a.ts"), ""); + yield* fileSystem.symlink("a.ts", path.join(cwd, "late/link.ts")); + // Folders fff lists but that are gone by the time they are read are skipped. + const firstPage = Array.from({ length: 1_000 }, (_, index) => `missing-${index}`); + const { directorySearch } = fakeSymlinkFinder([...firstPage, "late"]); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + + expect(yield* listedPaths(searchIndex)).toEqual(["late", "late/link.ts"]); + expect(directorySearch.mock.calls.map(([, options]) => options?.pageIndex)).toEqual([ + 0, 1_000, + ]); + }), + ).pipe(Effect.provide(testLayer)), +); + // fff reads HOME on Linux and macOS but the user profile on Windows, so a temp HOME cannot stand in there. it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "refuses to index a workspace rooted at the home directory", diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 5d5d39bf5561..22e307c18261 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off - readdir with file types avoids an lstat per child +import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; import type { @@ -7,11 +9,18 @@ import type { GrepCursor, MixedItem, Result, + WatchEvent, + WatchUnsubscribe, } from "@ff-labs/fff-node"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type { @@ -24,8 +33,10 @@ import type { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; +import { scoreSubsequenceMatch } from "@t3tools/shared/searchRanking"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { type IgnoreDecision, type IgnoreFile, parseIgnoreFile } from "./IgnorePatterns.ts"; // fff-node's native library stays external; single-executables require disk packages instead of importing them. const requireForFff = NodeModule.createRequire(import.meta.url); @@ -38,6 +49,13 @@ const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; const CONTENT_SEARCH_TIME_BUDGET_MS = 250; const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; +const SYMLINK_SCAN_CONCURRENCY = 16; +// A scan no watcher can invalidate is reused this long, so typing does not rescan every folder. +const UNWATCHED_SYMLINK_SCAN_TTL_MS = 2_000; +// A rescan that outlasts this leaves the read on the previous scan. +const SYMLINK_RESCAN_WAIT = "5 millis"; +// Each page is a synchronous native call; small pages let searches run between them. +const SYMLINK_DIRECTORY_PAGE_SIZE = 1_000; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedError()( "WorkspaceSearchIndexCreateFailed", @@ -290,6 +308,135 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } +interface ScannedDirectory { + readonly directory: string; + readonly links: ReadonlyArray; + /** Every symlink in the folder, including ones dropped for leaving the workspace. */ + readonly linkPaths: ReadonlyArray; + /** A nested repository (or the workspace's own), whose ignore rules own what is below it. */ + readonly isRepository: boolean; + readonly ignoreFile: string | undefined; +} + +const joinEntryPath = (directory: string, name: string) => + directory ? `${directory}/${name}` : name; + +/** + * fff skips symlinks, and `followSymlinks` drops file links, dedupes folder links, and walks out + * of the workspace. So links are read from the folders fff already indexed (ignore rules applied), + * without descending into them, and kept only when they resolve inside the workspace. + */ +const scanDirectory = Effect.fn("WorkspaceSearchIndex.scanDirectory")(function* ( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + cwd: string, + root: string, + directory: string, +) { + // Effect's readDirectory returns bare names; one lstat per child would cost far more. + const children = yield* Effect.promise(() => + NodeFSP.readdir(path.join(cwd, directory), { withFileTypes: true }).catch(() => []), + ); + const links: ProjectEntry[] = []; + const linkPaths: string[] = []; + let isRepository = false; + let ignoreFile: string | undefined; + for (const child of children) { + if (child.name === ".git") isRepository = true; + if (child.name === ".ignore" && child.isFile()) { + ignoreFile = yield* fileSystem + .readFileString(path.join(cwd, directory, child.name)) + .pipe(Effect.orElseSucceed(() => undefined)); + } + if (!child.isSymbolicLink()) continue; + const entryPath = joinEntryPath(directory, child.name); + linkPaths.push(entryPath); + const absolutePath = path.join(cwd, entryPath); + const target = yield* Effect.all([ + fileSystem.realPath(absolutePath), + fileSystem.stat(absolutePath), + ]).pipe(Effect.orElseSucceed(() => undefined)); + if (!target) continue; + const relative = path.relative(root, target[0]); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + relative.split(path.sep).includes(".git") || + (target[1].type !== "Directory" && target[1].type !== "File") + ) { + continue; + } + links.push({ path: entryPath, kind: target[1].type === "Directory" ? "directory" : "file" }); + } + return { directory, links, linkPaths, isRepository, ignoreFile } satisfies ScannedDirectory; +}); + +/** Whether `entryPath` is `directory` itself or below it. */ +const isWithin = (entryPath: string, directory: string) => + directory === "" || entryPath.startsWith(`${directory}/`); + +/** + * fff reads `.ignore` files alongside git's rules, and like ripgrep the nearest one that matches + * decides, ahead of any `.gitignore`. `ignoreFiles` is ordered deepest folder first. + */ +function decideByIgnoreFiles( + entry: ProjectEntry, + ignoreFiles: ReadonlyArray<{ readonly directory: string; readonly file: IgnoreFile }>, +): IgnoreDecision | undefined { + for (const { directory, file } of ignoreFiles) { + if (!isWithin(entry.path, directory) || entry.path === directory) continue; + const relative = directory ? entry.path.slice(directory.length + 1) : entry.path; + // Like git and ripgrep, a link is never a folder to a folder-only rule, whatever it targets. + const decision = file.decide(relative, false); + if (decision) return decision; + } + return undefined; +} + +/** Places each symlink before the first index result that matches the query less closely. */ +function mergeSymlinkMatches( + indexed: ProjectSearchEntriesResult, + linkPaths: ReadonlySet, + symlinks: ReadonlyArray, + query: string, + limit: number, +): ProjectSearchEntriesResult { + if (linkPaths.size === 0) return indexed; + // fff's watcher indexes links created after its scan, under a kind of its own choosing, so a + // path it already returned takes the link's kind (or drops out of a kind-filtered search). + const symlinkByPath = new Map(symlinks.map((entry) => [entry.path, entry])); + const score = (entry: ProjectEntry) => + scoreSubsequenceMatch(entry.path.toLowerCase(), query) ?? Number.POSITIVE_INFINITY; + const indexedPaths = new Set(indexed.entries.map((entry) => entry.path)); + const pending = symlinks + .flatMap((entry) => { + const entryScore = score(entry); + return Number.isFinite(entryScore) && !indexedPaths.has(entry.path) + ? [{ entry, score: entryScore }] + : []; + }) + .toSorted((left, right) => left.score - right.score); + const entries: ProjectEntry[] = []; + let nextPending = 0; + for (const indexedEntry of indexed.entries) { + const entry = linkPaths.has(indexedEntry.path) + ? symlinkByPath.get(indexedEntry.path) + : indexedEntry; + if (!entry) continue; + const entryScore = score(entry); + while (nextPending < pending.length && pending[nextPending]!.score < entryScore) { + entries.push(pending[nextPending++]!.entry); + } + entries.push(entry); + } + for (; nextPending < pending.length; nextPending += 1) entries.push(pending[nextPending]!.entry); + return { + entries: entries.slice(0, limit), + truncated: indexed.truncated || entries.length > limit, + }; +} + // fff applies .gitignore files only; Git also ignores paths from .git/info/exclude and the global excludes file. const listGitIgnoredPaths = Effect.fn("WorkspaceSearchIndex.listGitIgnoredPaths")(function* ( vcsProcess: VcsProcess.VcsProcess["Service"], @@ -401,6 +548,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( variant: WorkspaceSearchIndexVariant = "paths", ) { const hostIsWindows = yield* HostProcess.isWindows; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => Effect.try({ try: () => finder.destroy(), @@ -430,22 +579,32 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }), ); - const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); + // fff's isScanning takes the picker lock too, so progress is read off the JS thread. + const isScanning = Effect.promise(async () => { + try { + const progress = await finder.getScanProgressAsync(); + return !progress.ok || progress.value.isScanning; + } catch { + return true; + } + }); + const hasIncompleteInitialScan = () => (initialScanTimedOut ? isScanning : Effect.succeed(false)); const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", - execute: () => Result, + execute: () => Promise>, ): Effect.fn.Return { - const result = yield* Effect.try({ + // fff's searches wait on its picker lock, which its watcher can hold for minutes. + const result = yield* Effect.tryPromise({ try: execute, catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: `FileFinder.${operation} threw unexpectedly.`, + reason: `FileFinder.${operation} rejected unexpectedly.`, cause, }), }); @@ -460,12 +619,233 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( return result.value; }); + const root = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + + const listIndexedDirectories = Effect.fn("WorkspaceSearchIndex.listIndexedDirectories")( + function* () { + const directories: string[] = []; + // fff's pageIndex is an offset into the results, not a page number. + for (let pageIndex = 0; ; pageIndex += SYMLINK_DIRECTORY_PAGE_SIZE) { + const result = yield* Effect.promise(async () => { + try { + return await finder.directorySearchAsync("", { + pageIndex, + pageSize: SYMLINK_DIRECTORY_PAGE_SIZE, + }); + } catch { + return undefined; + } + }); + if (!result?.ok) return directories; + for (const item of result.value.items) { + const directory = trimDirectorySeparator(toPosixPath(item.relativePath, hostIsWindows)); + if (directory) directories.push(directory); + } + if (result.value.items.length < SYMLINK_DIRECTORY_PAGE_SIZE) return directories; + } + }, + ); + + /** Ignore rules belong to the repository that owns a path, so each one answers for its own. */ + const listGitIgnored = Effect.fn("WorkspaceSearchIndex.listGitIgnored")(function* ( + repository: string, + entries: ReadonlyArray, + ) { + const relativePaths = entries.map((entry) => + repository ? entry.path.slice(repository.length + 1) : entry.path, + ); + const repositoryCwd = path.join(cwd, repository); + const result = yield* vcsProcess + .run({ + operation: "WorkspaceSearchIndex.listGitIgnored", + command: "git", + // fff applies ignore rules to tracked paths too, so the index is not consulted. + args: ["-c", "core.fsmonitor=false", "check-ignore", "--no-index", "-z", "--stdin"], + cwd: repositoryCwd, + stdin: `${relativePaths.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 10_000, + maxOutputBytes: 16 * 1024 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + // Exit 1 means nothing matched. + if (result?.exitCode === 1) return []; + if (result?.exitCode !== 0 || result.stdoutTruncated) { + // Outside git no rules apply (git exits before reading stdin, so the cause is lost). + // Any other failure hides the repository's links rather than leaking ones it ignores. + const insideRepository = yield* vcsProcess + .run({ + operation: "WorkspaceSearchIndex.listGitIgnored", + command: "git", + args: ["rev-parse", "--git-dir"], + cwd: repositoryCwd, + timeoutMs: 10_000, + }) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + return insideRepository ? entries.map((entry) => entry.path) : []; + } + return result.stdout + .split("\0") + .filter(Boolean) + .map((relativePath) => (repository ? `${repository}/${relativePath}` : relativePath)); + }); + + const scanSymlinkEntries = Effect.fn("WorkspaceSearchIndex.scanSymlinkEntries")(function* () { + const indexComplete = !(yield* isScanning); + const directories = yield* listIndexedDirectories(); + const scanned = yield* Effect.forEach( + ["", ...directories], + (directory) => scanDirectory(fileSystem, path, cwd, root, directory), + { concurrency: SYMLINK_SCAN_CONCURRENCY }, + ); + const candidates = scanned.flatMap((directory) => directory.links); + const linkPaths = new Set(scanned.flatMap((directory) => directory.linkPaths)); + if (candidates.length === 0) return { entries: candidates, linkPaths, indexComplete }; + + const ignoreFiles = scanned + .flatMap(({ directory, ignoreFile }) => + ignoreFile === undefined ? [] : [{ directory, file: parseIgnoreFile(ignoreFile) }], + ) + .toSorted((left, right) => right.directory.length - left.directory.length); + const decisions = new Map( + candidates.map((entry) => [entry.path, decideByIgnoreFiles(entry, ignoreFiles)]), + ); + const repositories = [ + "", + ...scanned.flatMap(({ directory, isRepository }) => + isRepository && directory ? [directory] : [], + ), + ].toSorted((left, right) => right.length - left.length); + const entriesByRepository = Map.groupBy( + candidates.filter((entry) => decisions.get(entry.path) === undefined), + (entry) => + repositories.find( + (repository) => isWithin(entry.path, repository) && entry.path !== repository, + ) ?? "", + ); + const ignored = new Set( + (yield* Effect.forEach( + entriesByRepository, + ([repository, entries]) => listGitIgnored(repository, entries), + { concurrency: 4 }, + )).flat(), + ); + return { + entries: candidates.filter((entry) => + decisions.get(entry.path) === undefined + ? !ignored.has(entry.path) + : decisions.get(entry.path) === "included", + ), + linkPaths, + indexComplete, + }; + }); + + // One scan runs at a time and outlives the reads that started it. Only the first read waits for + // it in full: after that a change or expiry marks the result stale, and reads wait briefly for + // the rescan before answering from the last finished one, so file churn cannot stall search. + type SymlinkScan = Effect.Success>; + let lastSymlinkScan: SymlinkScan | undefined; + let lastSymlinkScanExpiresAt: number | undefined; + let pendingSymlinkScan: Deferred.Deferred | undefined; + let symlinkScanStale = false; + let knownLinkPaths: ReadonlySet = new Set(); + const invalidateSymlinkScan = () => { + symlinkScanStale = true; + }; + const isKnownLink = (absolutePath: string) => + [cwd, root].some((base) => + knownLinkPaths.has(toPosixPath(path.relative(base, absolutePath), hostIsWindows)), + ); + const onWatchBatch = (events: ReadonlyArray) => { + // fff reports a link replaced in place as `modified`, so edits to known links count too. + const editsOnly = events.every( + (event) => + event.kind === "modified" && + !/(^|[\\/])\.(git)?ignore$/.test(event.path) && + !isKnownLink(event.path), + ); + if (!editsOnly) invalidateSymlinkScan(); + }; + // fff refuses subscriptions until its watcher is up, which can trail the initial scan, so + // each read retries until one is in place. + let unwatch: WatchUnsubscribe | undefined; + yield* Effect.addFinalizer(() => Effect.sync(() => unwatch?.())); + const ensureWatching = () => { + if (unwatch) return true; + try { + const result = finder.watch(onWatchBatch); + if (result.ok) unwatch = result.value; + } catch { + // Not ready yet; the next read tries again. + } + return unwatch !== undefined; + }; + const indexScope = yield* Effect.scope; + const startSymlinkScan = Effect.fn("WorkspaceSearchIndex.startSymlinkScan")(function* () { + symlinkScanStale = false; + // Subscribing before the scan starts means no change during it goes unseen. + const watched = ensureWatching(); + const done = yield* Deferred.make(); + pendingSymlinkScan = done; + yield* scanSymlinkEntries().pipe( + Effect.tap((result) => + Effect.gen(function* () { + lastSymlinkScan = result; + knownLinkPaths = result.linkPaths; + // Without a watcher, or before fff has seen every folder, the result is kept only briefly. + lastSymlinkScanExpiresAt = + watched && result.indexComplete + ? undefined + : (yield* Clock.currentTimeMillis) + UNWATCHED_SYMLINK_SCAN_TTL_MS; + }), + ), + Effect.onExit((exit) => + Effect.sync(() => { + pendingSymlinkScan = undefined; + }).pipe(Effect.andThen(Deferred.done(done, exit))), + ), + Effect.forkIn(indexScope), + ); + return done; + }); + const listSymlinkEntries = Effect.fn("WorkspaceSearchIndex.listSymlinkEntries")(function* () { + if ( + lastSymlinkScanExpiresAt !== undefined && + (yield* Clock.currentTimeMillis) >= lastSymlinkScanExpiresAt + ) { + symlinkScanStale = true; + } + const pending = + pendingSymlinkScan ?? + (lastSymlinkScan === undefined || symlinkScanStale ? yield* startSymlinkScan() : undefined); + let result = lastSymlinkScan; + if (pending && result === undefined) { + result = yield* Deferred.await(pending); + } else if (pending) { + const fresh = yield* Deferred.await(pending).pipe(Effect.timeoutOption(SYMLINK_RESCAN_WAIT)); + if (Option.isSome(fresh)) result = fresh.value; + } + if (result === undefined) + return yield* Effect.die("The symlink scan finished without a result."); + return { + // Git's ignore rules hide a link the same way searchVisible hides the files it indexes. + entries: result.entries.filter( + (entry) => !isIgnoredPath(ignoredPaths, entry.path, hostIsWindows), + ), + linkPaths: result.linkPaths, + }; + }); + // Grows the first page until hidden entries can no longer crowd visible ones out of it. const searchVisible = Effect.fn("WorkspaceSearchIndex.searchVisible")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "mixedSearch", - execute: (pageSize: number) => Result>, + execute: (pageSize: number) => Promise>>, pathOf: (item: I) => string, ): Effect.fn.Return, WorkspaceSearchIndexSearchFailed> { let size = pageSize; @@ -522,21 +902,25 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }), ).pipe(Effect.catchTags({ WorkspaceSearchIndexScanTimedOut: () => Effect.void })); ignoredPaths = yield* listGitIgnoredPaths(vcsProcess, cwd, hostIsWindows); + invalidateSymlinkScan(); }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const incompleteBeforeQuery = hasIncompleteInitialScan(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan(); const result = yield* searchVisible( "", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", - (pageSize) => finder.mixedSearch("", { pageSize }), + (pageSize) => finder.mixedSearchAsync("", { pageSize }), mixedItemPath, ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES, hostIsWindows); - const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => - left.path.localeCompare(right.path), + const symlinks = yield* listSymlinkEntries(); + // fff's watcher can index a link the scan rejected; only accepted links are listed. + const indexed = mapped.entries.filter((entry) => !symlinks.linkPaths.has(entry.path)); + const sortedEntries = withDirectoryAncestors([...indexed, ...symlinks.entries]).toSorted( + (left, right) => left.path.localeCompare(right.path), ); const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); return { @@ -553,35 +937,45 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( kind: ProjectEntryKind | undefined, imageOnly: boolean | undefined, ) { - const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); + const { entries: allSymlinks, linkPaths } = yield* listSymlinkEntries(); + const symlinks = allSymlinks.filter((entry) => + imageOnly + ? entry.kind === "file" && isProjectFaviconPath(entry.path) + : kind === undefined || entry.kind === kind, + ); + // Index results that are filtered-out links get dropped in the merge, so fetch past them. + const indexLimit = limit + linkPaths.size - symlinks.length; + const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, indexLimit + 1); + const merge = (indexed: ProjectSearchEntriesResult) => + mergeSymlinkMatches(indexed, linkPaths, symlinks, query, limit); if (kind === "file" || imageOnly) { const result = yield* searchVisible( query, pageSize, "fileSearch", - (size) => finder.fileSearch(query, { pageSize: size }), + (size) => finder.fileSearchAsync(query, { pageSize: size }), (item) => item.relativePath, ); - return mapFileSearchResult(result, limit, hostIsWindows, imageOnly); + return merge(mapFileSearchResult(result, indexLimit, hostIsWindows, imageOnly)); } if (kind === "directory") { const result = yield* searchVisible( query, pageSize, "directorySearch", - (size) => finder.directorySearch(query, { pageSize: size }), + (size) => finder.directorySearchAsync(query, { pageSize: size }), (item) => item.relativePath, ); - return mapDirectorySearchResult(result, limit, hostIsWindows); + return merge(mapDirectorySearchResult(result, indexLimit, hostIsWindows)); } const result = yield* searchVisible( query, pageSize, "mixedSearch", - (size) => finder.mixedSearch(query, { pageSize: size }), + (size) => finder.mixedSearchAsync(query, { pageSize: size }), mixedItemPath, ); - return mapMixedSearchResult(result, limit, hostIsWindows); + return merge(mapMixedSearchResult(result, indexLimit, hostIsWindows)); }); const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( @@ -591,7 +985,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( // matching is subsequence-based, so dropping them still matches "[id]" and "a{b}" paths. // "{" is dropped only when the query finds nothing as a glob, so "*.{ts,tsx}" keeps working. const query = rawQuery.replaceAll("[", ""); - const incompleteBeforeQuery = hasIncompleteInitialScan(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan(); let mapped = yield* searchNames(query, limit, kind, imageOnly); if (mapped.entries.length === 0 && query.includes("{")) { mapped = yield* searchNames(query.replaceAll("{", ""), limit, kind, imageOnly); @@ -616,7 +1010,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( do { const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); const result = yield* runSearch(input.query, input.limit, "grep", () => - finder.grep(searchQuery, { + finder.grepAsync(searchQuery, { mode: regexMode ? "regex" : "plain", smartCase: !input.caseSensitive && !regexMode, maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts index ee423c47d06a..dee23025dbf4 100644 --- a/apps/server/src/workspace/restoreMissingWorktree.ts +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -1,17 +1,22 @@ import { ThreadId, type ProjectId, type TerminalCwdNotFoundError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as ServerConfig from "../config.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import { scratchFolderRoot } from "../project/ManagedProjectFolders.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import { isWithinRoot } from "./KnownWorkspaces.ts"; +import { withWorkspaceLease } from "./workspaceLease.ts"; import { TerminalCwdRecovery } from "../terminal/Manager.ts"; -class WorktreeRecreateError extends Schema.TaggedError()( +export class WorktreeRecreateError extends Schema.TaggedError()( "WorktreeRecreateError", { worktreePath: Schema.String, @@ -33,6 +38,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError root)); + if (path.dirname(folder) !== root && path.dirname(folder) !== realRoot) return; + yield* Effect.logWarning("recreating missing scratch folder", { + threadId: thread.id, + folder, + }); + yield* fileSystem.makeDirectory(folder, { recursive: true }); + }, + // Turn start reports a folder that is still missing. + Effect.orElseSucceed(() => undefined), + ); + + // Only folders holding nothing but leftovers are cleared, never user files. + const isLeftoverFolder = (worktreePath: string) => + fileSystem.exists(path.join(worktreePath, ".git")).pipe( + Effect.flatMap((hasGit) => + hasGit + ? Effect.succeed(false) + : fileSystem.readDirectory(worktreePath).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (entry) => + LEFTOVER_WORKTREE_ENTRIES.has(entry) + ? fileSystem + .stat(path.join(worktreePath, entry)) + .pipe(Effect.map((info) => info.type === "Directory")) + : Effect.succeed(false), + ), + ), + Effect.map((leftovers) => leftovers.every(Boolean)), + ), + ), + Effect.orElseSucceed(() => false), + ); const resolveRecreateBase = Effect.fn("restoreMissingWorktree.resolveRecreateBase")(function* ( cwd: string, @@ -114,9 +167,11 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { return Effect.fn("restoreMissingWorktree")(function* (thread: WorktreeThread) { const { worktreePath, branch } = thread; - if (worktreePath === null || branch === null) return; + if (worktreePath === null) return; + if (branch === null) return yield* recreateScratchFolder(thread, worktreePath); const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true)); - if (exists || !(yield* isManagedWorktreePath(worktreePath))) return; + const leftover = exists && (yield* isLeftoverFolder(worktreePath)); + if ((exists && !leftover) || !(yield* isManagedWorktreePath(worktreePath))) return; const project = yield* projects.getById(thread.projectId).pipe( Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), @@ -127,7 +182,9 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { worktreePath, branch, }); - yield* recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch }).pipe( + // `git worktree add` refuses a non-empty target. + yield* (leftover ? fileSystem.remove(worktreePath, { recursive: true }) : Effect.void).pipe( + Effect.andThen(recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch })), Effect.mapError((cause) => cause._tag === "WorktreeRecreateNoBaseError" ? cause @@ -137,6 +194,31 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { }); }); +export class ThreadWorktreeRestore extends Context.Service< + ThreadWorktreeRestore, + { + readonly restore: ( + thread: WorktreeThread, + ) => Effect.Effect; + } +>()("t3/workspace/restoreMissingWorktree/ThreadWorktreeRestore") {} + +// Turn start restores under the same lease, so concurrent restores see each other's worktree. +export const layerThreadWorktreeRestore = Layer.effect( + ThreadWorktreeRestore, + Effect.gen(function* () { + const path = yield* Path.Path; + const restore = yield* makeRestoreMissingWorktree; + return { + restore: Effect.fn("ThreadWorktreeRestore.restore")(function* (thread: WorktreeThread) { + return yield* thread.worktreePath === null + ? restore(thread) + : withWorkspaceLease(path.resolve(thread.worktreePath), restore(thread)); + }), + }; + }), +); + // Worktree setup uses terminals, so recovery must stay outside TerminalManager. export const makePrepareTerminalLaunch = Effect.fn( "restoreMissingWorktree.makePrepareTerminalLaunch", diff --git a/apps/server/src/workspace/wslDrivePath.ts b/apps/server/src/workspace/wslDrivePath.ts new file mode 100644 index 000000000000..188e1c85f45e --- /dev/null +++ b/apps/server/src/workspace/wslDrivePath.ts @@ -0,0 +1,29 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isWindowsDrivePath } from "@t3tools/shared/path"; +import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +// WSL mounts Windows drives under a configurable automount root, so only wslpath knows where. +// No WSL env check: servers started by systemd lack WSL_DISTRO_NAME, and off WSL the spawn just fails. +// Untraced: it runs for every chip path, and only drive paths on Linux do any work. +export const toWslDrivePath = Effect.fnUntraced(function* (requestedPath: string) { + // spawn throws on a NUL byte instead of failing. + if (!isWindowsDrivePath(requestedPath) || requestedPath.includes("\0")) return requestedPath; + if ((yield* HostProcess.Platform) !== "linux") return requestedPath; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner + .string( + ChildProcess.make("wslpath", ["-u", requestedPath], { + env: yield* HostProcess.Environment, + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.timeout("1 second"), + Effect.orElseSucceed(() => ""), + ); + const linuxPath = output.endsWith("\n") ? output.slice(0, -1) : output; + return linuxPath.startsWith("/") ? linuxPath : requestedPath; +}); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 41fffc754265..ee20f684d8e6 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1203,6 +1203,7 @@ it.effect("access changes made while the snapshot loads reach the client after i Layer.mock(ServerEnvironment.ServerEnvironment)({}), Layer.mock(EnvironmentAuth.EnvironmentAuth)({ listPairingLinks: () => Effect.succeed([]), + listConnectDevices: () => Effect.succeed([]), // Another client revokes a session after the snapshot read it. listClientSessions: () => PubSub.publish(sessionChanges, { @@ -1246,6 +1247,18 @@ it.effect("access changes made while the snapshot loads reach the client after i type: "clientRemoved", payload: { sessionId: revokedElsewhere }, }); + yield* server.write(0, { _tag: "Ack", requestId }); + assert.equal((yield* Queue.take(responses)).type, "pairingLinkRemoved"); + yield* server.write(0, { _tag: "Ack", requestId }); + + // Device trust has no incremental event, so a change re-sends the whole snapshot. + yield* PubSub.publish(pairingChanges, { type: "connectDevicesChanged" }); + assert.deepEqual(yield* Queue.take(responses), { + version: 1, + revision: 4, + type: "snapshot", + payload: { pairingLinks: [], clientSessions: [], connectDevices: [] }, + }); yield* server.write(0, { _tag: "Interrupt", requestId, interruptors: [] }); }).pipe( Effect.provide( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 58e832e7636e..6005aa2dfe77 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -733,7 +733,10 @@ const serverWsRpcs = Array.from(ServerWsRpcGroup.requests.values()); const SplitWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isSplitWsRpc)); const OtherWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isOtherWsRpc)); function toAuthAccessStreamEvent( - change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, + change: Exclude< + PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, + { readonly type: "connectDevicesChanged" } + >, revision: number, currentSessionId: AuthSessionId, ): AuthAccessStreamEvent { @@ -1787,6 +1790,7 @@ export const layerWsRpc = ( Effect.all({ pairingLinks: serverAuth.listPairingLinks(), clientSessions: serverAuth.listClientSessions(currentSessionId), + connectDevices: serverAuth.listConnectDevices(), }).pipe( Effect.mapError( (error) => @@ -1892,6 +1896,7 @@ export const layerWsRpc = ( }), newProjectsRoot: managedFolders.namedProjectsRoot, ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), + serverTime: DateTime.formatIso(yield* DateTime.now), }; }); @@ -2991,7 +2996,7 @@ export const layerWsRpc = ( new ProjectMutationError({ commandId: mutation.commandId, message: - cause._tag === "ProjectNotEmptyError" + cause._tag === "ProjectNotEmptyError" || cause._tag === "ProjectInvalidError" ? cause.message : "Failed to mutate project.", cause, @@ -3179,7 +3184,15 @@ export const layerWsRpc = ( [WS_METHODS.vcsCreateWorktree]: (input) => gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsRemoveWorktree]: (input) => - gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + gitWorkflow.removeWorktree(input).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + // Subscribers of the removed checkout learn it is no longer a repository. + Effect.tap(() => + vcsStatusBroadcaster + .refreshLocalStatus(input.path) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid), + ), + ), [WS_METHODS.vcsCreateRef]: (input) => gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsSwitchRef]: (input) => @@ -3486,17 +3499,28 @@ export const layerWsRpc = ( const initialSnapshot = yield* loadAuthAccessSnapshot(); const revisionRef = yield* Ref.make(1); - const liveEvents: Stream.Stream = Stream.fromQueue( - liveBuffer, - ).pipe( - Stream.mapEffect((change) => - Ref.updateAndGet(revisionRef, (revision) => revision + 1).pipe( - Effect.map((revision) => - toAuthAccessStreamEvent(change, revision, currentSessionId), + const liveEvents: Stream.Stream = + Stream.fromQueue(liveBuffer).pipe( + Stream.mapEffect((change) => + Ref.updateAndGet(revisionRef, (revision) => revision + 1).pipe( + Effect.flatMap((revision) => + // Device trust has no incremental event older clients could decode. + change.type === "connectDevicesChanged" + ? loadAuthAccessSnapshot().pipe( + Effect.map((payload): AuthAccessStreamEvent => ({ + version: 1, + revision, + type: "snapshot", + payload, + })), + ) + : Effect.succeed( + toAuthAccessStreamEvent(change, revision, currentSessionId), + ), + ), ), ), - ), - ); + ); return Stream.concat( rpcInitialItems([ diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 0f87728ac7c3..2d442275f789 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import { resolveTaggedSourceVersion } from "../../scripts/lib/tagged-source-version.ts"; import rootPackageJson from "../../package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" }; import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts"; @@ -18,9 +19,12 @@ import { export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); -const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version) - ? "nightly" - : "latest"; +const serverManifestPath = NodePath.join(import.meta.dirname, "package.json"); +// The desktop packager passes APP_VERSION; it can equal the manifest at a commit tagged otherwise. +const buildVersion = + process.env.APP_VERSION?.trim() || + resolveTaggedSourceVersion(NodePath.join(import.meta.dirname, "../.."), packageJson.version); +const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(buildVersion) ? "nightly" : "latest"; // Official release workflows set the telemetry channel; local builds and forks use "dev". const TELEMETRY_CHANNELS = ["stable", "nightly", "preview", "dev"]; const telemetryChannel = process.env.T3CODE_TELEMETRY_CHANNEL?.trim() || "dev"; @@ -88,6 +92,19 @@ export default mergeConfig( }, }, pack: { + plugins: [ + { + // Every server module reads its version from this manifest import. + name: "t3code:build-version", + load(id) { + if (NodePath.resolve(id) !== serverManifestPath) return; + return { + code: JSON.stringify({ ...packageJson, version: buildVersion }), + moduleType: "json", + }; + }, + }, + ], // SEA embeds one entry; expose the history worker as a hidden subcommand. entry: packBootstrap ? ["src/bin.ts"] diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index e32e8f76f1a9..a881650f69bc 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -46,6 +46,7 @@ export interface ServerBrowserHandle { readonly navigate: (url: string) => void; readonly history: (delta: -1 | 1) => void; readonly reload: (options?: { readonly ignoreCache?: boolean }) => void; + readonly stopLoading: () => void; readonly viewport: (setting: PreviewViewportSetting) => void; readonly canvas: () => HTMLCanvasElement | null; } @@ -311,6 +312,7 @@ export function ServerBrowserSurface(props: { history: (delta) => send({ type: "history", delta }), reload: (options) => send({ type: "reload", ...(options?.ignoreCache ? { ignoreCache: true } : {}) }), + stopLoading: () => send({ type: "stopLoading" }), viewport: (setting) => send({ type: "viewport", setting }), canvas: () => (!pdfUrl && hasFrameRef.current ? canvasRef.current : null), }), diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index bf1c2da40db8..5cc74bb1b7f0 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -5,6 +5,7 @@ import { type DesktopPreviewTabDefaults, type PreviewAppearancePreference, type PreviewViewportSetting, + type PreviewZoomFactor, } from "@t3tools/contracts"; import { @@ -17,7 +18,7 @@ import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout"; export interface BrowserDefaults { readonly viewport: PreviewViewportSetting; - readonly zoomFactor: number; + readonly zoomFactor: PreviewZoomFactor; readonly appearance: PreviewAppearancePreference; readonly autoShowFloatingPreview: boolean; readonly profiles: ReadonlyArray; @@ -26,7 +27,7 @@ export interface BrowserDefaults { const toBrowserDefaults = (settings: { readonly browserDefaultViewport: PreviewViewportSetting; - readonly browserDefaultZoomFactor: number; + readonly browserDefaultZoomFactor: PreviewZoomFactor; readonly browserDefaultAppearance: PreviewAppearancePreference; readonly browserAutoShowFloatingPreview: boolean; readonly browserProfiles: ReadonlyArray; diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index f1c4e65a9991..74330bf3edbd 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -7,6 +7,9 @@ import { import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; import { readEnvironmentScope } from "~/state/session"; import { isPreviewAvailableFor } from "./previewRuntime"; +import { isWebUrl } from "./webUrl"; + +export { isWebUrl }; export interface ResolveLinkTargetInput { readonly url: string; @@ -27,15 +30,6 @@ export function resolveLinkTarget(input: ResolveLinkTargetInput): BrowserLinkTar return "app"; } -export function isWebUrl(url: string): boolean { - try { - const { protocol } = new URL(url); - return protocol === "http:" || protocol === "https:"; - } catch { - return false; - } -} - /** Before hydration, the default "system" would ignore a saved "app" link target. */ export async function resolveBrowserLinkTargetPreference(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index a997ee2b552e..db1a58e98dfb 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -85,7 +85,9 @@ export async function openUrlInPreview(input: { url: input.url, viewport: defaults.viewport, profileId: input.profileId ?? defaults.profileId, - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); return mapAtomCommandResult(result, (snapshot) => { diff --git a/apps/web/src/browser/webUrl.ts b/apps/web/src/browser/webUrl.ts new file mode 100644 index 000000000000..19e637794b74 --- /dev/null +++ b/apps/web/src/browser/webUrl.ts @@ -0,0 +1,8 @@ +export function isWebUrl(url: string): boolean { + try { + const { protocol } = new URL(url); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 3d9c064cfcb2..3bb70c8b609e 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -26,6 +26,7 @@ import { useAddProjectTargets } from "../hooks/useAddProjectTargets"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { + COPIED_FILES_MAX_COUNT, COPYABLE_FILE_MAX_BYTES, addProjectToMachine, isDefaultCopiedFile, @@ -35,6 +36,7 @@ import { newProjectId } from "../lib/utils"; import { readProject, subscribeProject, useProject } from "../state/entities"; import { useEnvironments } from "../state/environments"; import { projectEnvironment } from "../state/projects"; +import { useDebouncedValue } from "../state/queries"; import { useEnvironmentQuery } from "../state/query"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Button } from "./ui/button"; @@ -121,8 +123,10 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const [pathOverride, setPathOverride] = useState(null); const destinationPath = pathOverride ?? defaultPath; - const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; - const ignoredFiles = useEnvironmentQuery( + const capabilities = source?.serverConfig?.environment.capabilities; + const canListFiles = capabilities?.ignoredFileListing === true; + const canSearchFiles = canListFiles && capabilities?.ignoredFileSearch === true; + const allIgnoredFiles = useEnvironmentQuery( project && canListFiles ? projectEnvironment.listIgnoredFiles({ environmentId: project.environmentId, @@ -130,13 +134,29 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : null, ); + const [fileQuery, setFileQuery] = useState(""); + const sentFileQuery = useDebouncedValue(fileQuery.trim(), 120); + const searching = canSearchFiles && sentFileQuery.length > 0; + const searchedFiles = useEnvironmentQuery( + project && searching + ? projectEnvironment.listIgnoredFiles({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot, query: sentFileQuery }, + }) + : null, + ); + const ignoredFiles = searching ? searchedFiles : allIgnoredFiles; const files = ignoredFiles.data?.files ?? []; - const [selected, setSelected] = useState | null>(null); - const selectedFiles = selected ?? defaultSelection(files); + // Keyed by path with the file kept, so picks made under one search survive the next. + const [selected, setSelected] = useState | null>(null); + const selectedFiles = selected ?? defaultSelection(allIgnoredFiles.data?.files ?? []); const copyable = files.filter((file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES); - const selectedBytes = files - .filter((file) => selectedFiles.has(file.path)) - .reduce((total, file) => total + file.byteLength, 0); + const allCopyableSelected = copyable.every((file) => selectedFiles.has(file.path)); + const atSelectionLimit = selectedFiles.size >= COPIED_FILES_MAX_COUNT; + const selectedBytes = [...selectedFiles.values()].reduce( + (total, file) => total + file.byteLength, + 0, + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -146,11 +166,19 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const unavailable = project === null || target === null || - (canListFiles && ignoredFiles.data === null && ignoredFiles.error === null); - const toggleFile = (path: string, checked: boolean) => { - const next = new Set(selectedFiles); - if (checked) next.add(path); - else next.delete(path); + (canListFiles && allIgnoredFiles.data === null && allIgnoredFiles.error === null); + const toggleFile = (file: ProjectIgnoredFile, checked: boolean) => { + const next = new Map(selectedFiles); + if (!checked) next.delete(file.path); + else if (!atSelectionLimit) next.set(file.path, file); + setSelected(next); + }; + const toggleShownFiles = () => { + const next = new Map(selectedFiles); + for (const file of copyable) { + if (allCopyableSelected) next.delete(file.path); + else if (next.size < COPIED_FILES_MAX_COUNT) next.set(file.path, file); + } setSelected(next); }; @@ -198,7 +226,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe title: project.title, remoteUrl, destinationPath: destinationPath.trim(), - files: files.filter((file) => selectedFiles.has(file.path)).map((file) => file.path), + files: [...selectedFiles.keys()], }); close(); openClonedProject(scopeProjectRef(target.environmentId, projectId)); @@ -334,33 +362,41 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe {copyable.length > 0 ? ( <> - {selectedFiles.size} of {files.length} + {selectedFiles.size} of {allIgnoredFiles.data?.files.length ?? 0} {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} - ) : null} + {canSearchFiles && (allIgnoredFiles.data?.truncated || fileQuery.length > 0) ? ( + setFileQuery(event.target.value)} + // Enter would otherwise submit the clone. + onKeyDown={(event) => { + if (event.key === "Enter") event.preventDefault(); + }} + spellCheck={false} + autoComplete="off" + /> + ) : null} {ignoredFiles.error ? (

{ignoredFiles.error}

) : files.length === 0 ? (

- {ignoredFiles.isSuccess - ? "No ignored files to copy." - : "Finding ignored files…"} + {!ignoredFiles.isSuccess + ? "Finding ignored files…" + : searching + ? "No ignored files match." + : "No ignored files to copy."}

) : (
@@ -369,7 +405,8 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe key={file.path} file={file} checked={selectedFiles.has(file.path)} - onCheckedChange={(checked) => toggleFile(file.path, checked)} + disabled={atSelectionLimit && !selectedFiles.has(file.path)} + onCheckedChange={(checked) => toggleFile(file, checked)} /> ))}
@@ -377,6 +414,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe {ignoredFiles.data?.truncated ? (

Showing the first {files.length} files. + {canSearchFiles ? " Search to find the rest." : ""}

) : null}

@@ -415,29 +453,37 @@ function projectSubdirectory(repositoryRoot: string | undefined, workspaceRoot: : ""; } -function defaultSelection(files: readonly ProjectIgnoredFile[]): ReadonlySet { - return new Set( +function defaultSelection( + files: readonly ProjectIgnoredFile[], +): ReadonlyMap { + return new Map( files .filter( (file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES && isDefaultCopiedFile(file.path), ) - .map((file) => file.path), + .map((file) => [file.path, file]), ); } function IgnoredFileRow({ file, checked, + disabled, onCheckedChange, }: { file: ProjectIgnoredFile; checked: boolean; + disabled: boolean; onCheckedChange: (checked: boolean) => void; }) { const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; return (

{embedded ? null : } @@ -4057,6 +4059,12 @@ export default function ChatView(props: ChatViewProps) { ); } +// Chromium on Linux pastes PRIMARY into the focused editable unless the middle mouseup is cancelled. +function preventMiddleClickPasteOutsideEditable(event: ReactMouseEvent) { + if (event.button !== 1 || isEditableFocused(event.target)) return; + event.preventDefault(); +} + function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) { usePreviewSession(props.threadRef); return null; diff --git a/apps/web/src/components/CommandPalette.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx index 5a2682df6f65..e1f69d72e3b4 100644 --- a/apps/web/src/components/CommandPalette.composition.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -8,9 +8,32 @@ import { CommandPalette } from "./CommandPalette"; import { openCommandPalette } from "../commandPaletteBus"; import { primaryServerKeybindingsAtom } from "../state/server"; -const scopes = vi.hoisted(() => ({ denied: undefined as string | undefined })); -const setup = (deniedScope?: string) => { +const scopes = vi.hoisted(() => ({ + denied: undefined as string | undefined, + sourceControl: undefined as unknown, +})); +const setup = (deniedScope?: string, sourceControl?: unknown) => { scopes.denied = deniedScope; + scopes.sourceControl = sourceControl; +}; +const githubAvailable = { + versionControlSystems: [], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + status: "available", + version: { _tag: "None" }, + installHint: "Install GitHub CLI", + detail: { _tag: "None" }, + auth: { + status: "authenticated", + account: { _tag: "Some", value: "demo" }, + host: { _tag: "None" }, + detail: { _tag: "None" }, + }, + }, + ], }; vi.mock("@effect/atom-react", async (original) => ({ @@ -101,7 +124,7 @@ vi.mock("../state/filesystem", async (original) => ({ })); vi.mock("../state/query", async (original) => ({ ...(await original()), - useEnvironmentQuery: () => ({ data: undefined, isLoading: false }), + useEnvironmentQuery: () => ({ data: scopes.sourceControl, isLoading: false }), })); vi.mock("./settings/useAvailableSettingsSearchItems", () => ({ useAvailableSettingsSearchItems: () => [], @@ -208,3 +231,15 @@ it.each([ await userEvent.keyboard("{Tab}"); await expect.element(submit).toHaveFocus(); }); + +it("tells screen readers whether a new project will be published", async () => { + setup(undefined, githubAvailable); + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText("New project", { exact: true }).click(); + await page.getByRole("combobox").fill("demo-project"); + const option = page.getByRole("option", { name: /Create private repository/ }); + await expect.element(option).toHaveAttribute("aria-checked", "false"); + await option.click(); + await expect.element(option).toHaveAttribute("aria-checked", "true"); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index db7ea4895b7c..895995c03e48 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -213,6 +213,8 @@ export interface CommandPaletteActionItem extends CommandPaletteItem { readonly run: () => Promise; /** A pin toggle on the row, also reached with the pin shortcut while the row is highlighted. */ readonly pin?: CommandPaletteItemPin; + /** Set on toggle rows; exposed to assistive technology as `aria-checked`. */ + readonly checked?: boolean; } export interface CommandPaletteItemPin { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 99c87a939dd8..fe21bd2e3a63 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2427,6 +2427,7 @@ function OpenCommandPaletteDialog(props: { ), ...(isCreatingNewProject ? { disabled: true } : {}), keepOpen: true, + checked: newProjectPublishes, run: async () => { setNewProjectPublishes((publishes) => !publishes); }, diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 864c190b89c5..8c52ad294fb4 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -250,6 +250,9 @@ function CommandPaletteResultRow(props: { {...(props.index === undefined ? {} : { index: props.index })} value={props.item.value} active={props.isActive} + {...(props.item.kind === "action" && props.item.checked !== undefined + ? { "aria-checked": props.item.checked } + : {})} onPointerDown={(event) => { if (props.item.threadRef) { startPaletteThreadDrag( diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index cd01804f8efe..480269d4326b 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -467,6 +467,21 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy expect(serializeEditorDoc(editor.state.doc).value).toBe(value); }); +// regression: #25180 Tiptap's paste rules styled the pasted text a second time +it.each([ + "Read https://example.com/_handle_/status/1 and open src/__test__.tsx", + "see `**x**` now", +])("sends pasted %s as pasted", async (pasted) => { + await using screen = await mountEditor(""); + const { editor } = screen; + await act(async () => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", pasted); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(pasted); +}); + it.each(["x\r\n\r\ny", "x\r\ry"])( "pastes %j with the same blank line as x\\n\\ny", async (text) => { @@ -599,6 +614,31 @@ describe("Markdown markers around styled text", () => { expected: "**bo**\n**ld** after", marks: { bold: ["bo", "ld"] }, }, + { + name: "a line break after typed inline code leaves the next line plain", + value: "", + caret: 1, + keys: "`Test`{Shift>}{Enter}{/Shift}f", + expected: "`Test`\nf", + marks: { code: ["Test"] }, + }, + { + name: "a line break after typed bold leaves the next line plain", + value: "", + caret: 1, + keys: "**Test**{Shift>}{Enter}{/Shift}f", + expected: "**Test**\nf", + marks: { bold: ["Test"] }, + }, + { + name: "bold turned on after typed bold carries across a line break", + value: "", + caret: 1, + keys: `**Test**${isMacPlatform(navigator.platform) ? "{Meta>}b{/Meta}" : "{Control>}b{/Control}"}{Shift>}{Enter}{/Shift}f`, + expected: "**Test**\n**f**", + // The caret beside f shows it as source. + marks: { bold: ["Test"] }, + }, { name: "splitting a list item inside bold keeps both halves bold", value: "- **bold** after", diff --git a/apps/web/src/components/ConnectDeviceApprovalCoordinator.tsx b/apps/web/src/components/ConnectDeviceApprovalCoordinator.tsx new file mode 100644 index 000000000000..fc7a04f80d0e --- /dev/null +++ b/apps/web/src/components/ConnectDeviceApprovalCoordinator.tsx @@ -0,0 +1,178 @@ +import { useNavigate } from "@tanstack/react-router"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { AuthAccessWriteScope, type EnvironmentId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { useEffect, useRef, useState } from "react"; + +import { authEnvironment } from "../state/auth"; +import { useEnvironments } from "../state/environments"; +import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentScope } from "../state/session"; +import { useAtomCommand } from "../state/use-atom-command"; +import { describeConnectDeviceClient } from "./settings/ConnectionsSettings.logic"; +import { toastManager } from "./ui/toast"; +import { stackedThreadToast } from "./ui/toastHelpers"; + +/** Asks whoever can approve to look at T3 Connect devices waiting on an environment. */ +export function ConnectDeviceApprovalCoordinator() { + const { environments } = useEnvironments(); + return environments.map((environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.settings.requireConnectDeviceApproval === true ? ( + + ) : null, + ); +} + +function EnvironmentConnectDeviceRequests(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const canApprove = useEnvironmentScope(props.environmentId, AuthAccessWriteScope); + return canApprove ? : null; +} + +/** Reports a failed command and says whether it failed, so its request can be offered again. */ +async function reportFailure( + title: string, + action: () => Promise>, +): Promise { + const result = await action(); + if (result._tag !== "Failure") return false; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return true; +} + +function ConnectDeviceRequestToasts({ + environmentId, + environmentLabel, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const navigate = useNavigate(); + const access = useEnvironmentQuery(authEnvironment.accessChanges({ environmentId, input: null })); + const approve = useAtomCommand(authEnvironment.approveConnectDevice, { reportFailure: false }); + const deny = useAtomCommand(authEnvironment.revokePairingLink, { reportFailure: false }); + const toasts = useRef(new Map()); + const [, setRetryGeneration] = useState(0); + // A failed approve or deny forgets its toast and re-renders, so the request is offered again. + const settle = async (id: string, failed: Promise) => { + if (!(await failed)) return; + toasts.current.delete(id); + setRetryGeneration((generation) => generation + 1); + }; + + const requests = + access.data?.type === "snapshot" + ? access.data.payload.pairingLinks.flatMap((link) => + link.connectDeviceRequest + ? [{ id: link.id, request: link.connectDeviceRequest, expiresAt: link.expiresAt }] + : [], + ) + : null; + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens, updates and closes imperative toastManager toasts to match the pending requests + useEffect(() => { + if (requests === null) return; + // The server publishes no event when a request expires, so the toast ends itself then. + const now = Date.now(); + const live = requests.flatMap((entry) => { + const remainingMs = DateTime.toEpochMillis(entry.expiresAt) - now; + return remainingMs > 0 ? [{ ...entry, remainingMs }] : []; + }); + const pendingIds = new Set(live.map(({ id }) => id)); + for (const [id, tracked] of toasts.current) { + if (pendingIds.has(id)) continue; + toastManager.close(tracked.toastId); + toasts.current.delete(id); + } + for (const { id, request, remainingMs } of live) { + const device = describeConnectDeviceClient(request); + const renderedKey = `${request.verificationCode}:${device}`; + const tracked = toasts.current.get(id); + if (tracked?.renderedKey === renderedKey) continue; + const close = () => { + const current = toasts.current.get(id); + if (current) toastManager.close(current.toastId); + }; + const options = stackedThreadToast({ + type: "info", + title: `A device wants to connect to ${environmentLabel}`, + description: `${device}. Approve only if it shows code ${request.verificationCode}.`, + timeout: remainingMs, + actionProps: { + children: "Approve", + onClick: () => { + close(); + void settle( + id, + reportFailure("Could not approve the device", () => + approve({ environmentId, input: { pairingLinkId: id } }), + ), + ); + }, + }, + data: { + hideCopyButton: true, + secondaryActionProps: { + children: "Deny", + onClick: () => { + close(); + void settle( + id, + reportFailure("Could not deny the device", () => + deny({ environmentId, input: { id } }), + ), + ); + }, + }, + additionalActions: [ + { + id: "review", + props: { + children: "Review", + onClick: () => + void navigate({ to: "/settings/access", search: { machine: environmentId } }), + }, + }, + ], + }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(id, { toastId: tracked.toastId, renderedKey }); + } else { + toasts.current.set(id, { toastId: toastManager.add(options), renderedKey }); + } + } + }); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes this environment's imperative toasts when it stops needing approval + useEffect(() => { + const tracked = toasts.current; + return () => { + for (const { toastId } of tracked.values()) toastManager.close(toastId); + tracked.clear(); + }; + }, []); + + return null; +} diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 5f789469af76..51ba65933dff 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -4,6 +4,7 @@ import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId, + RunId, ThreadId, type ClientSettings, type ReviewBranchCommitsResult, @@ -35,6 +36,8 @@ const state = vi.hoisted(() => ({ localRefs: null as unknown as ResultAtom, remoteRefs: null as unknown as ResultAtom, commits: null as unknown as ResultAtom, + checkpoints: [] as unknown[], + checkpointDiff: null as { diff: string } | null, })); vi.mock("~/state/server", () => ({ @@ -59,7 +62,7 @@ vi.mock("~/state/vcs", () => ({ vi.mock("~/state/entities", () => ({ useQuietThreadShell: () => state.thread, useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }), - useThreadCheckpointSummaries: () => [], + useThreadCheckpointSummaries: () => state.checkpoints, useQuietThreadVisibleTurnItems: () => [], })); vi.mock("~/state/filesystem", () => ({ @@ -77,7 +80,7 @@ vi.mock("~/hooks/useTheme", () => ({ subscribeRenderedAppearance: () => () => {}, })); vi.mock("~/state/queries", () => ({ - useCheckpointDiff: () => ({ data: null, error: null, isPending: false }), + useCheckpointDiff: () => ({ data: state.checkpointDiff, error: null, isPending: false }), useTurnItemDetail: () => ({ data: null, error: null, isPending: false }), useDebouncedValue: (value: A) => value, })); @@ -126,6 +129,8 @@ function setup(failedBaseRef: string | null) { }; reset(); state.diffFilesCollapsed = false; + state.checkpoints = []; + state.checkpointDiff = null; const patch = Atom.make>( AsyncResult.initial(), ); @@ -267,3 +272,33 @@ it.each([false, true])( await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); }, ); + +it("says a turn diff cut off at the server's size limit is incomplete", async () => { + using context = setup("origin/orphan"); + state.checkpoints = [ + { + runId: "run", + checkpointTurnCount: 1, + checkpointRef: "ref", + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:00:00Z", + }, + ]; + state.checkpointDiff = { + diff: "diff --git a/first.txt b/first.txt\n--- a/first.txt\n+++ b/first.txt\n@@ -0,0 +1 @@\n+visible\n\n[truncated]", + }; + useDiffPanelStore.getState().selectTurn(context.threadRef, RunId.make("run")); + await using screen = await render( + +
+ +
+
, + ); + await expect.element(screen.getByText("first.txt", { exact: true }).first()).toBeVisible(); + await expect + .element(screen.getByText("This preview exceeds the size limit. Changes shown are incomplete.")) + .toBeVisible(); +}); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 7c75bfea3433..2b0a6a78d4ec 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -6,7 +6,12 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ReviewBranchCommitsResult, ScopedThreadRef, RunId } from "@t3tools/contracts"; +import { + CHECKPOINT_DIFF_TRUNCATED_MARKER, + type ReviewBranchCommitsResult, + type ScopedThreadRef, + type RunId, +} from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -483,7 +488,10 @@ export default function DiffPanel({ : gitDiff; const isSelectedPatchTruncated = toolCallPreview?.truncated === true || - (isGitSelection && selectedGitSource?.truncated === true); + (isGitSelection && selectedGitSource?.truncated === true) || + (!toolCallSelection && + selectedTurn !== undefined && + selectedPatch?.endsWith(CHECKPOINT_DIFF_TRUNCATED_MARKER) === true); const selectedPatchQuery = toolCallSelection ? toolCallDetail : selectedTurn diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 99fa3abceee6..a019b729d319 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -92,6 +92,7 @@ import { useVcsPullAction, } from "../state/sourceControlActions"; import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { useProjectClone } from "~/state/projectClones"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { useFilesystemReadAccess } from "~/state/filesystem"; @@ -124,6 +125,7 @@ import { type OfferedSourceControlProviders, } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import { useOpenLink } from "~/browser/useOpenLink"; @@ -1148,9 +1150,17 @@ export default function GitActionsControl({ activeDraftThread?.envMode === "worktree" && activeDraftThread.worktreePath === null; + // Until a clone finishes, the status names an unborn HEAD that the repository may never have. + const activeProjectClone = useProjectClone( + activeDraftThread + ? scopeProjectRef(activeDraftThread.environmentId, activeDraftThread.projectId) + : null, + ); + const isProjectCloning = activeProjectClone !== null && activeProjectClone.phase !== "done"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- persists the draft thread branch when the live git status subscription reports a different branch useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { + if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread || isProjectCloning) { return; } @@ -1168,6 +1178,7 @@ export default function GitActionsControl({ activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, + isProjectCloning, isSelectingWorktreeBase, ]); diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 10bf817b7363..d2a15729b0df 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -71,4 +71,36 @@ describe("HistorySwipeNavigation", () => { expect(navigateHistory).not.toHaveBeenCalled(); }); + + // A vertical scroll 15° off vertical. + const drift = Array.from({ length: 30 }, () => [-4, 15] as const); + it.each([ + [ + "a vertical scroll that starts slightly diagonally keeps the thread", + [[-5, 3], ...drift], + false, + ], + [ + "a swipe that leans diagonally for two events navigates", + [ + [-5, 3], + [-2, 2], + [-150, 0], + ], + true, + ], + ["a single diagonal event past the trigger keeps the thread", [[-130, 100]], false], + ] as const)("%s", async (_name, events, navigates) => { + navigateHistory.mockReset(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using screen = await render(); + const panel = screen.container.appendChild(document.createElement("div")); + for (const [deltaX, deltaY] of [...events, [0, 0] as const]) { + panel.dispatchEvent( + new WheelEvent("wheel", { deltaX, deltaY, bubbles: true, composed: true }), + ); + } + + expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0); + }); }); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index e1adc04491a9..2924f82cd313 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -7,6 +7,10 @@ import { canNavigateHistory, navigateHistory, type HistoryDirection } from "../n const SWIPE_TRIGGER_DISTANCE = 120; // Movement needed before the gesture picks an axis; first events are often diagonal. const SWIPE_AXIS_LOCK_DISTANCE = 8; +// Vertical scrolls drift sideways, so a swipe's horizontal travel must clearly dominate once +// it has moved far enough for its direction to be more than noise. +const SWIPE_HORIZONTAL_DOMINANCE = 2; +const SWIPE_DOMINANCE_DISTANCE = 30; const MOMENTUM_SAMPLE_SIZE = 5; type SwipeBounds = { left: number; top: number; width: number; height: number }; @@ -17,6 +21,8 @@ type SwipeGesture = direction: HistoryDirection; distance: number; deltas: number[]; + travelX: number; + travelY: number; y: number; bounds: SwipeBounds; } @@ -52,11 +58,19 @@ function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): bo return false; } +function isVerticalScroll(travelX: number, travelY: number): boolean { + return ( + travelX + travelY >= SWIPE_DOMINANCE_DISTANCE && travelX <= SWIPE_HORIZONTAL_DOMINANCE * travelY + ); +} + function lockSwipeAxis( pending: Extract, ): Exclude { const { path, sumX, sumY } = pending; - if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" }; + if (Math.abs(sumX) <= Math.abs(sumY) || isVerticalScroll(Math.abs(sumX), Math.abs(sumY))) { + return { kind: "ignored" }; + } if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) { return { kind: "ignored" }; } @@ -68,6 +82,8 @@ function lockSwipeAxis( direction, distance: Math.abs(sumX), deltas: [], + travelX: Math.abs(sumX), + travelY: Math.abs(sumY), y: pending.y, bounds: readMainContentBounds(), }; @@ -134,6 +150,12 @@ export function HistorySwipeNavigation() { } else { const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; if (event.deltaX === 0 && event.deltaY === 0) return release(); + gesture.travelX += Math.abs(event.deltaX); + gesture.travelY += Math.abs(event.deltaY); + if (isVerticalScroll(gesture.travelX, gesture.travelY)) { + gesture = { kind: "ignored" }; + return hideIndicator(); + } gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; if (isMomentumTail(gesture.deltas)) return release(); gesture.distance = Math.max(0, gesture.distance + delta); diff --git a/apps/web/src/components/HostedEmptyState.tsx b/apps/web/src/components/HostedEmptyState.tsx index 627ba0b758eb..86427ba736f1 100644 --- a/apps/web/src/components/HostedEmptyState.tsx +++ b/apps/web/src/components/HostedEmptyState.tsx @@ -10,6 +10,7 @@ import { isElectron } from "../env"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { useRelayEnvironmentDiscovery } from "../state/environments"; import { InstallT3Commands } from "./InstallT3Commands"; +import { retryClerkLoad, useClerkLoadFailed } from "./clerk/clerkLoadFailure"; import { useT3ConnectAuthPrompt } from "./clerk/useT3ConnectAuthPrompt"; import { CloudEnvironmentConnectRows } from "./cloud/CloudEnvironmentConnectList"; import { Button } from "./ui/button"; @@ -65,6 +66,7 @@ export function HostedEmptyState() { function T3ConnectSteps() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const loadFailed = useClerkLoadFailed(); const { openAuthPrompt } = useT3ConnectAuthPrompt(); // Adding the first computer unmounts this screen. Wait for all health checks // so every compatible registration is queued before that happens. @@ -76,7 +78,11 @@ function T3ConnectSteps() { return ( <> - {isSignedIn ? ( + {loadFailed ? ( + + ) : isSignedIn ? (

Signed in diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx index 3d7e5520ac8c..3fe69b8b16d1 100644 --- a/apps/web/src/components/ProjectMonogram.tsx +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -17,6 +17,7 @@ export function ProjectMonogram({ return (

Panel content
+ , + ); + const dialog = page.getByRole("dialog"); + await expect.element(dialog).toBeVisible(); + await expect.element(dialog).not.toHaveAccessibleName(""); +}); diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index 0cad2af5009d..56846307b83d 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -1,6 +1,6 @@ import { type ReactNode } from "react"; -import { Sheet, SheetPopup } from "./ui/sheet"; +import { Sheet, SheetHeader, SheetPopup, SheetTitle } from "./ui/sheet"; export function RightPanelSheet(props: { animationDurationMs: number; @@ -24,6 +24,9 @@ export function RightPanelSheet(props: { keepMounted className="w-[min(42vw,28rem)] min-w-80 max-w-[28rem] max-[760px]:w-[min(88vw,24rem)] max-[760px]:min-w-0 wco:mt-(--workspace-topbar-height) wco:h-[calc(100%-var(--workspace-topbar-height))] wco:max-h-[calc(100%-var(--workspace-topbar-height))]" > + + Workspace panel + {props.children} diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 22270787dcc9..9f0204cd70c3 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -73,8 +73,10 @@ it("retains visible output and selection until a replacement subscription receiv threadId: "thread", terminalId: "term-1", }); + terminal.resetAndWrite.mockClear(); synchronizeTerminalOutput(terminal, cleared, observedCursor); - expect(terminal.resetAndWrite).toHaveBeenLastCalledWith(""); + expect(terminal.resetAndWrite).not.toHaveBeenCalled(); + expect(terminal.write).toHaveBeenLastCalledWith("\u001b[H\u001b[2J\u001b[3J"); }); it("reports an attach failure that leaves the output unchanged", () => { diff --git a/apps/web/src/components/auth/ApproveAgentSurface.tsx b/apps/web/src/components/auth/ApproveAgentSurface.tsx index 9f4863fe97d5..6568327d5d0e 100644 --- a/apps/web/src/components/auth/ApproveAgentSurface.tsx +++ b/apps/web/src/components/auth/ApproveAgentSurface.tsx @@ -13,6 +13,7 @@ import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "rea import { isElectron } from "../../env"; import { runConnectMcpApproval } from "../../state/connectMcpApproval"; import { resolveClerkSignInProps } from "../clerk/authRedirect"; +import { retryClerkLoad, useClerkLoadFailed } from "../clerk/clerkLoadFailure"; import { Button } from "../ui/button"; import { Spinner } from "../ui/spinner"; import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; @@ -86,6 +87,7 @@ export function ApproveAgentSurface() { ); const clerk = useClerk(); const { isLoaded, isSignedIn } = useAuth(); + const loadFailed = useClerkLoadFailed(); const session = useAtomValue(managedRelaySessionAtom); const signInOpened = useRef(false); @@ -118,6 +120,21 @@ export function ApproveAgentSurface() { ); } + if (loadFailed) { + return ( + +
+ +
+
+ ); + } + if (!isLoaded || (isSignedIn && session === null)) { return ( diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index ce1745a6e51f..580de34b9d95 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -52,6 +52,10 @@ function markdownDestination(src: string): string { return `<${escaped}>`; } +function parseImageDimension(value: string | number | undefined): number { + return Number(typeof value === "string" ? value.trim().replace(/px$/i, "") : value); +} + /** * `maxHeightRem` folds a height cap into the width bound: `max-height` alone * would not feed back through `aspect-ratio` once `width` is definite, so a @@ -62,8 +66,8 @@ export function authoredImageSizeStyle( height: string | number | undefined, maxHeightRem = 30, ): CSSProperties | undefined { - const parsedWidth = Number(width); - const parsedHeight = Number(height); + const parsedWidth = parseImageDimension(width); + const parsedHeight = parseImageDimension(height); const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; if (hasWidth && hasHeight) { @@ -293,6 +297,7 @@ export function ChatMarkdownVideo(props: { readonly copyMarkdown: string | undefined; readonly originalUrl?: string | undefined; readonly sourceFailed?: boolean | undefined; + readonly unavailableDetail?: string | undefined; readonly style?: CSSProperties | undefined; readonly mediaIdentity?: string | undefined; readonly actionsSource?: MediaActionSource | undefined; @@ -303,6 +308,7 @@ export function ChatMarkdownVideo(props: { key={props.mediaIdentity ?? props.copyMarkdown ?? props.src} src={props.src} sourceFailed={props.sourceFailed} + unavailableDetail={props.unavailableDetail} label={props.alt} originalUrl={props.originalUrl} style={props.style} @@ -322,19 +328,61 @@ export function ChatMarkdownVideo(props: { ); } +type ChatMarkdownAssetResource = Extract< + AssetResource, + { + readonly _tag: + | "attachment" + | "workspace-file" + | "media-file" + | typeof SOURCE_CONTROL_MEDIA_ASSET + | "tool-output-image"; + } +>; + +export function assetMediaActionsSource(input: { + readonly environmentId: EnvironmentId; + readonly resource: ChatMarkdownAssetResource; + readonly kind: "image" | "video"; + readonly name: string; + readonly src: string | null; + readonly workspaceRoot?: string | undefined; + readonly originalUrl?: string | undefined; + readonly downloadable?: boolean | undefined; +}): MediaActionSource { + const { environmentId, resource } = input; + const path = + resource._tag === "media-file" + ? resource.path + : resource._tag === "workspace-file" && input.workspaceRoot + ? `${input.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` + : undefined; + const reference = path + ? mediaFileReference(path, input.workspaceRoot) + : input.originalUrl + ? mediaUrlReference(input.originalUrl) + : undefined; + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + return { + kind: input.kind, + name: input.name, + src: input.src, + ...(input.downloadable === false ? {} : { asset: { environmentId, resource } }), + ...(reference ? { reference } : {}), + ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") + ? { + onOpenFile: () => + useRightPanelStore + .getState() + .openFile({ environmentId, threadId: resource.threadId }, relativePath), + } + : {}), + }; +} + export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract< - AssetResource, - { - readonly _tag: - | "attachment" - | "workspace-file" - | "media-file" - | typeof SOURCE_CONTROL_MEDIA_ASSET - | "tool-output-image"; - } - >; + readonly resource: ChatMarkdownAssetResource; readonly kind?: "image" | "video"; readonly alt: string; readonly copyMarkdown?: string; @@ -366,19 +414,12 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props : undefined, ); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); - const path = - resource._tag === "media-file" - ? resource.path - : resource._tag === "workspace-file" && props.workspaceRoot - ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` - : undefined; - const reference = path - ? mediaFileReference(path, props.workspaceRoot) - : props.originalUrl - ? mediaUrlReference(props.originalUrl) - : undefined; - const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; + const missingFileAccess = + assetUrl._tag === "Failure" && + assetUrl.reason === "missing-file-access" && + fallbackSrc === undefined; + const unavailableDetail = missingFileAccess ? MISSING_FILE_ACCESS_MESSAGE : undefined; const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") @@ -394,38 +435,30 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props : maxHeightRem !== 30 ? { maxHeight: `${maxHeightRem}rem` } : undefined); - const actionsSource: MediaActionSource = { + const actionsSource = assetMediaActionsSource({ + environmentId: props.environmentId, + resource, kind: props.kind ?? "image", name: props.alt || (props.kind ?? "image"), src, - ...(fallbackSrc === undefined - ? { asset: { environmentId: props.environmentId, resource } } - : {}), - ...(reference ? { reference } : {}), - ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") - ? { - onOpenFile: () => - useRightPanelStore - .getState() - .openFile( - { environmentId: props.environmentId, threadId: resource.threadId }, - relativePath, - ), - } - : {}), - }; + workspaceRoot: props.workspaceRoot, + originalUrl: props.originalUrl, + downloadable: fallbackSrc === undefined, + }); if (props.kind === "video") { return ( ); @@ -436,13 +469,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} src={src} sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined} - unavailableDetail={ - assetUrl._tag === "Failure" && - assetUrl.reason === "missing-file-access" && - fallbackSrc === undefined - ? MISSING_FILE_ACCESS_MESSAGE - : undefined - } + unavailableDetail={unavailableDetail} alt={props.alt} copyMarkdown={props.copyMarkdown} standalone={props.standalone ?? true} diff --git a/apps/web/src/components/chat/ChatOverlayHosts.tsx b/apps/web/src/components/chat/ChatOverlayHosts.tsx index 6f96399f9731..55e58ca7e24b 100644 --- a/apps/web/src/components/chat/ChatOverlayHosts.tsx +++ b/apps/web/src/components/chat/ChatOverlayHosts.tsx @@ -196,11 +196,14 @@ export function openRevertConfirm(threadKey: string, pending: PendingRevert): vo export function RevertConfirmHost(props: { readonly threadKey: string; readonly checkpoints: OrchestrationV2ThreadProjection["checkpoints"] | undefined; + readonly worktreePath: string | null; readonly onConfirm: (turnCount: number, messageId: MessageId, restoreFiles: boolean) => void; }) { const pending = useThreadOverlay(revertConfirmAtom, props.threadKey); + // The server refuses file restore outside an isolated worktree. const canRestoreFiles = pending !== null && + props.worktreePath !== null && findRevertCheckpoint(props.checkpoints ?? [], pending.turnCount)?.status === "ready"; const confirm = (restoreFiles: boolean) => { if (pending === null || (restoreFiles && !canRestoreFiles)) return; diff --git a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx index 9dadb999da85..bb03e19dd130 100644 --- a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx @@ -7,12 +7,14 @@ import { render } from "../../../test/render"; import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; import { ComposerContextPermissions } from "../ComposerContextPermissions"; import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { focusRequestedPermission, markOpenToPermissions, useComposerMenuProps, } from "./composerEventScope"; import { ComposerFooterModeControls, ComposerPermissionsSection } from "./ComposerFooterControls"; +import { ComposerPickerFooter } from "./ComposerModelEffort"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; const options = runtimeModeOptions.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); @@ -95,6 +97,49 @@ it("returns focus to the editor after a pick", async () => { await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); }); +// Mirrors the model picker: a composer popover whose footer nests the Permissions menu. +function PickerWithPermissions() { + const [mode, setMode] = useState("full-access"); + const composerMenuProps = useComposerMenuProps(); + return ( + <> +

Timeline

+ + Model + + + + + + ); +} + +it("on a touch tablet, tapping outside the Permissions menu leaves the editor unfocused", async () => { + const realMatchMedia = window.matchMedia.bind(window); + using _touch = vi + .spyOn(window, "matchMedia") + .mockImplementation((query) => realMatchMedia(query === "(pointer: coarse)" ? "all" : query)); + const { handle, ui } = withComposer(); + await using screen = await render(ui, { router: false }); // no routes involved + await userEvent.click(screen.getByRole("button", { name: "Model" })); + await userEvent.click(page.getByRole("button", { name: "Permissions: Full access" })); + const fullAccess = page.getByRole("menuitemradio", { name: "Full access" }); + await expect.element(fullAccess).toBeInTheDocument(); + const permissionsTrigger = page.getByRole("button", { name: "Permissions: Full access" }); + // The first outside tap closes the Permissions menu, the second the picker around it. + await userEvent.click(document.body, { position: { x: 1, y: 1 } }); + await expect.element(fullAccess).not.toBeInTheDocument(); + await userEvent.click(screen.getByText("Timeline")); + await expect.element(permissionsTrigger).not.toBeInTheDocument(); + // Focusing the editor after a tap opens the on-screen keyboard on Android tablets. + expect(handle.focusAtEnd).not.toHaveBeenCalled(); +}); + it("opens on the checked permission when composer.mode marked the trigger", async () => { await using screen = await render(, { router: false }); // no routes involved const trigger = screen.getByRole("button", { name: "More" }); diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index f4a7fbfb7c13..5aeb5c028cde 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -297,7 +297,7 @@ function LinkRequestDialog(props: { {steps.length > 1 && !useExisting ? ( - Create a link on {targetName}: Settings → Connections → Create link, then - paste its code here. + Create a link on {targetName}: Settings → Sharing & access → Authorized + clients → Create link, then paste its code here. )} @@ -393,7 +393,7 @@ function LinkRequestDialog(props: { {useExisting ? (

This environment is already linked to {targetName}. The agent can use that link as - it is; change its access in Settings → Connections. + it is; change its access in Settings → Environments.

) : null} {error !== null ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 77da83deffe2..8ac12b54a4e5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -86,6 +86,10 @@ vi.mock(import("~/assets/assetUrls"), async (original) => { stubbedAsset.url ? { _tag: "Success", url: stubbedAsset.url } : actual.useAssetUrlState(...args)) as typeof actual.useAssetUrlState, + useAssetUrls: ((...args: Parameters) => + stubbedAsset.url + ? args[1].map(() => stubbedAsset.url) + : actual.useAssetUrls(...args)) as typeof actual.useAssetUrls, }; }); @@ -1711,6 +1715,47 @@ describe("MessagesTimeline attachments", () => { }), ); }); + + it("opens an image from an expanded tool row as a gallery of the turn's viewed images", async () => { + stubbedAsset.url = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + onTestFinished(() => { + stubbedAsset.url = ""; + }); + const onImageExpand = vi.fn["onImageExpand"]>(); + const read = (name: string) => + work(`read-${name}`, { + label: `Read ${name}`, + toolTitle: "Read", + tone: "tool", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + viewedImagePath: `/repo/${name}`, + }); + await using screen = await renderTimeline( + [read("first.png"), read("second.png"), read("third.png")], + { onImageExpand, workspaceRoot: "/repo" }, + ); + await click(screen.button(/^Read 3 files/)); + await vi.waitFor(() => expect(screen.buttons("Read")).toHaveLength(3)); + await click(screen.buttons("Read")[1]!); + const inRow = await vi.waitFor(() => { + const image = [...screen.container.querySelectorAll("img")].find( + (img) => img.alt === "second.png" && !img.closest("[data-image-gallery]"), + ); + expect(image).toBeTruthy(); + return image!; + }); + await click(inRow.closest("button, [role=button]")!); + expect(onImageExpand).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + index: 1, + images: ["first.png", "second.png", "third.png"].map((name) => + expect.objectContaining({ name }), + ), + }), + ); + }); }); describe("MessagesTimeline thread find", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 1bdb26aecfd6..7bbdf9db1784 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4888,6 +4888,113 @@ describe("live subagents after their parent turn settles", () => { }); }); +describe("parent run waiting on foreground subagents", () => { + const runId = RunId.make("parent-run"); + const threadId = ThreadId.make("parent-thread"); + const at = (second: number) => + DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`); + const base = (id: string, second: number) => ({ + id: TurnItemId.make(id), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: ProviderTurnId.make("parent-provider-turn"), + nativeItemRef: null, + parentItemId: null, + ordinal: second, + title: null, + startedAt: at(second), + completedAt: at(second), + updatedAt: at(second), + }); + const child = (id: string, second: number): OrchestrationV2TurnItem => ({ + ...base(id, second), + status: "running", + completedAt: null, + type: "subagent", + subagentId: NodeId.make(id), + origin: "provider_native", + driver: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + childThreadId: null, + prompt: `Read ${id}`, + result: null, + }); + + const liveRow = (later: ReadonlyArray, toolCallVisibility?: "hideAll") => + deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: ( + [ + { + ...base("user", 0), + status: "completed", + type: "user_message", + messageId: MessageId.make("user"), + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + text: "Research these in parallel", + attachments: [], + }, + { + ...base("assistant", 1), + status: "completed", + type: "assistant_message", + messageId: MessageId.make("assistant"), + text: "Dispatching three subagents.", + streaming: false, + }, + child("a", 2), + { ...child("b", 3), status: "completed", completedAt: at(3) }, + child("c", 4), + ...later, + ] satisfies OrchestrationV2TurnItem[] + ).map((item) => ({ + position: item.ordinal, + visibility: "local" as const, + sourceThreadId: threadId, + sourceItemId: item.id, + item, + })), + optimisticMessages: [], + }), + latestRun: { + runId, + status: "running", + startedAt: DateTime.formatIso(at(0)), + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: DateTime.formatIso(at(0)), + turnDiffSummaries: [], + supportsConversationRollback: false, + ...(toolCallVisibility ? { toolCallVisibility } : {}), + }).find((row) => row.id === "live-activity-row"); + const thinking = { + kind: "thinking", + id: "live-activity-row", + createdAt: DateTime.formatIso(at(0)), + }; + + it("names the running children while the open run has done nothing since launching them", () => { + expect(liveRow([])).toEqual({ ...thinking, label: "Waiting on 2 subagents" }); + expect(liveRow([], "hideAll")).toEqual({ ...thinking, label: "Waiting on 2 subagents" }); + }); + + it("keeps Thinking when a hidden newer parent command follows the children", () => { + const command: OrchestrationV2TurnItem = { + ...base("command", 5), + status: "running", + completedAt: null, + type: "command_execution", + input: "sleep 5", + }; + expect(liveRow([command], "hideAll")).toEqual(thinking); + }); +}); + describe("subagent continued by a later turn", () => { const threadId = ThreadId.make("parent-thread"); const run1 = RunId.make("run-1"); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 90690dc0810e..9208e409ba20 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,10 @@ import * as DateTime from "effect/DateTime"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; -import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + isLiveSubagentTurnItem, + waitingOnSubagentsLabel, +} from "@t3tools/client-runtime/state/subagentRuntime"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; @@ -82,7 +85,9 @@ export function timelineEntryRunId(entry: TimelineEntry): RunId | null { if (entry.kind === "proposed-plan") { return entry.proposedPlan.runId; } - if (entry.kind === "html-render" || entry.kind === "mcp-app") return entry.runId; + if (entry.kind === "html-render" || entry.kind === "mcp-app" || entry.kind === "sent-message") { + return entry.runId; + } return entry.kind === "work" ? (entry.entry.runId ?? null) : null; } @@ -530,6 +535,8 @@ type MessagesTimelineRowContent = kind: "thinking"; id: string; createdAt: string | null; + /** Replaces "Thinking" while the run has done nothing since launching children that still run. */ + label?: string; groupId?: string; expanded?: boolean; } @@ -634,6 +641,12 @@ type MessagesTimelineRowContent = createdAt: string; htmlRender: HtmlRenderReference; } + | { + kind: "sent-message"; + id: string; + createdAt: string; + text: string; + } | { kind: "mcp-app"; id: string; @@ -817,6 +830,7 @@ function deriveSupersededAttemptFolds( (entry.kind === "event" && entry.projectedItem.item.type === "handoff") || entry.kind === "html-render" || entry.kind === "mcp-app" || + entry.kind === "sent-message" || timelineEntryIsPersistentResourceCard(entry) || liveSubagentEntryIds.has(entry.id) || (entry.kind === "work" && entry.entry.itemType === "system_notice") @@ -1442,6 +1456,19 @@ export function liveAnswerPreview(entries: ReadonlyArray): string return null; } +/** Reads entries before tool visibility filters them, so hidden newer parent work still counts. */ +function runningSubagentsAtRunTail(entries: ReadonlyArray, runId: RunId): number { + let running = 0; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]!; + if (entry.kind !== "event") break; + const { item } = entry.projectedItem; + if (item.type !== "subagent" || item.runId !== runId) break; + if (isLiveSubagentTurnItem(item)) running += 1; + } + return running; +} + function timelineRowEntries( input: Pick< MessagesTimelineRowsInput, @@ -2126,6 +2153,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (timelineEntry.kind === "sent-message") { + nextRows.push({ + kind: "sent-message", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + text: timelineEntry.text, + }); + continue; + } + if (timelineEntry.kind === "mcp-app") { nextRows.push({ kind: "mcp-app", @@ -2297,10 +2334,15 @@ export function deriveMessagesTimelineRows(input: { } nextRows.push(...viewedImagesRows(groupId, failedGroupAnchor.createdAt, groupedEntries)); } else { + const waitingOn = + unsettledRunId === null + ? 0 + : runningSubagentsAtRunTail(input.timelineEntries, unsettledRunId); nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt: input.activeTurnStartedAt ?? null, + ...(waitingOn > 0 ? { label: waitingOnSubagentsLabel(waitingOn) } : {}), }); } } @@ -2660,7 +2702,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean ); case "thinking": { const bt = b as typeof a; - return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded; + return ( + a.createdAt === bt.createdAt && + a.groupId === bt.groupId && + a.expanded === bt.expanded && + a.label === bt.label + ); } case "worktree-setup": return a.snapshot === (b as typeof a).snapshot; @@ -2720,6 +2767,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bh.createdAt && htmlRenderReferencesEqual(a.htmlRender, bh.htmlRender); } + case "sent-message": { + const bs = b as typeof a; + return a.createdAt === bs.createdAt && a.text === bs.text; + } + case "mcp-app": { const bm = b as typeof a; return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index eb4bd6fea6ef..83a616a0e860 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -126,6 +126,7 @@ import { AssistantMetaTimelineRow, ProposedPlanTimelineRow, HtmlRenderTimelineRow, + SentMessageTimelineRow, McpAppTimelineRow, } from "./TimelineAssistantRows"; import { V2EventTimelineRow } from "./TimelineV2EventRow"; @@ -1626,6 +1627,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "event" || row.kind === "attempt-fold" || row.kind === "html-render" || + row.kind === "sent-message" || row.kind === "mcp-app" || row.kind === "viewed-images" ? "pb-2" @@ -1676,6 +1678,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "assistant-meta" ? : null} {row.kind === "proposed-plan" ? : null} {row.kind === "html-render" ? : null} + {row.kind === "sent-message" ? : null} {row.kind === "viewed-images" ? : null} {row.kind === "mcp-app" ? : null} {row.kind === "working" ? : null} diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 36c04da89327..6af0004ba24d 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -1,6 +1,7 @@ import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; import { type RunId, type TurnItemId } from "@t3tools/contracts"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; +import { agentWorkingDirectories } from "@t3tools/client-runtime/workspace-basename-lookup"; import { memo, use, useState } from "react"; import { type ChatMessage, type TurnDiffSummary } from "../../types"; import ChatMarkdown from "../ChatMarkdown"; @@ -27,6 +28,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; +import { readThreadTurnItems } from "../../state/entities"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading } from "./TimelineRowParts"; @@ -58,6 +60,11 @@ export function AssistantTimelineRow({ row }: { row: Extract + ctx.threadRef && row.projectedItem + ? agentWorkingDirectories(readThreadTurnItems(ctx.threadRef), row.projectedItem) + : [] + } threadRef={ctx.threadRef ?? undefined} isStreaming={Boolean(row.message.streaming)} lineBreaks={shouldPreserveAssistantLineBreaks(messageText)} @@ -96,6 +103,32 @@ export function AssistantTimelineRow({ row }: { row: Extract; +}) { + const ctx = use(TimelineRowCtx); + + return ( +
+ T3 Code + +
+ ); +} + function AssistantForkButton({ projectedItem, }: { diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index c4593d2f6c5e..3fee2134c3f5 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -313,6 +313,7 @@ function AutomationMessageRow({ row }: { row: Extract Automation {/* Outside the fold so a collapsed message still has a heading to navigate to. */} {mediaAboveText ? null : ( diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index dfefdedeed52..eecd3c50bdd7 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -32,6 +32,8 @@ import { } from "../../session-logic"; import ChatMarkdown from "../ChatMarkdown"; import { ChatMarkdownAssetImage } from "./ChatMarkdownMedia"; +import type { ExpandedImagePreview } from "./ExpandedImagePreview"; +import { workGroupImageGallery } from "./markdownImageGallery"; import { ChevronRightIcon, CircleAlertIcon, XIcon, RotateCcwIcon } from "lucide-react"; import { Button, InlineButton } from "../ui/button"; import { useAssetUrls } from "../../assets/assetUrls"; @@ -355,6 +357,13 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { workspaceRoot, }) : null; + const groupViewedImages = groupView?.viewedImages; + // Images in this entry's expanded body page through the group's viewed images, like its strip. + const expandImage = + viewedImage && groupViewedImages + ? (preview: ExpandedImagePreview) => + onImageExpand(workGroupImageGallery(groupViewedImages, viewedImagePath, preview)) + : onImageExpand; const canExpand = previewText.trim().length > 0 || (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) || @@ -558,7 +567,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { srcFragment={viewedImage.srcFragment} workspaceRoot={workspaceRoot} maxHeightRem={16} - onImageExpand={onImageExpand} + onImageExpand={expandImage} /> ) : null} @@ -582,7 +591,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { cwd={ctx.markdownCwd} workspaceRoot={workspaceRoot} onOpenToolDiff={ctx.onOpenToolDiff} - onImageExpand={ctx.onImageExpand} + onImageExpand={expandImage} source={ctx.turnItemSource} /> ) : ( @@ -595,7 +604,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { onInitialOutputReady={onInitialOutputReady} projectedItem={workEntry.projectedItem} environmentId={ctx.activeThreadEnvironmentId} - onImageExpand={onImageExpand} + onImageExpand={expandImage} source={ctx.turnItemSource} /> ) : null} diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts b/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts index e4a8fe4a583c..bb53e5fa896d 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts @@ -1,20 +1,20 @@ +import { toServerClock } from "@t3tools/client-runtime/clock"; +import type { EnvironmentId } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -function formatWorkingTimer(startIso: string, endIso: string): string | null { +export function formatWorkingTimerNow(startIso: string, environmentId: EnvironmentId): string { const startedAtMs = Date.parse(startIso); - const endedAtMs = Date.parse(endIso); - if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { - return null; + if (!Number.isFinite(startedAtMs)) { + return "0s"; } - const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); + const elapsedSeconds = Math.max( + 0, + Math.floor((toServerClock(environmentId, Date.now()) - startedAtMs) / 1000), + ); if (elapsedSeconds < 60) { return `${elapsedSeconds}s`; } return formatDuration(elapsedSeconds * 1_000); } - -export function formatWorkingTimerNow(startIso: string): string { - return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; -} diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 2791c9b71490..1784f38da455 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -2,8 +2,13 @@ import { TimelinePendingApprovalRow } from "./TimelinePendingApprovalRow"; import { WorkLogButton, WorkLogList, WorkLogRow } from "./WorkLog"; import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; import { type ToolActivityIcon } from "@t3tools/contracts"; -import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation"; +import { + liveThoughtLine, + resolveViewedImageAsset, + workEntriesViewedImagePaths, +} from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { useAssetUrls } from "~/assets/assetUrls"; import { observeResize } from "~/lib/observeResize"; import { tickTextContent } from "~/lib/tickTextContent"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; @@ -11,6 +16,7 @@ import { memo, use, useLayoutEffect, useRef, useState, type ReactNode } from "re import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { workEntryDisplayIndicatesToolFailure } from "../../session-logic"; import ChatMarkdown from "../ChatMarkdown"; +import { assetMediaActionsSource } from "./ChatMarkdownMedia"; import { Minimize2Icon, XIcon } from "lucide-react"; import { Button } from "../ui/button"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; @@ -30,6 +36,7 @@ import { TimelineRowCtx, TimelineRowActivityCtx, WorkGroupViewCtx, + type WorkGroupViewedImage, type TimelineWorkEntry, type TimelineRow, } from "./timelineRowContext"; @@ -95,6 +102,7 @@ export const WorkGroupSection = memo(function WorkGroupSection({ anchorKey={anchorKey} disclosureAnchorKey={disclosureAnchorKey} entries={nonEmptyEntries} + groupedEntries={groupedEntries} workspaceRoot={workspaceRoot} /> ); @@ -119,9 +127,10 @@ export const WorkGroupSection = memo(function WorkGroupSection({ }); function WorkingTimer({ createdAt }: { createdAt: string }) { + const { activeThreadEnvironmentId } = use(TimelineRowCtx); return ( formatWorkingTimerNow(createdAt), true)} + ref={tickTextContent(() => formatWorkingTimerNow(createdAt, activeThreadEnvironmentId), true)} className="tabular-nums" /> ); @@ -129,15 +138,60 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { const compactWorkEntryHeight = 24; +// The thumbnail strip below the group is its own virtualized row, so the gallery an expanded +// entry opens is built from the same entries rather than from the strip's mounted images. +function useWorkGroupViewedImages( + groupedEntries: ReadonlyArray, +): ReadonlyArray { + const { threadRef, workspaceRoot, activeThreadEnvironmentId } = use(TimelineRowCtx); + const viewed = threadRef + ? workEntriesViewedImagePaths(groupedEntries).flatMap((path) => { + const asset = resolveViewedImageAsset(path, { + threadId: threadRef.threadId, + workspaceRoot, + }); + return asset ? [{ path, asset }] : []; + }) + : []; + const environmentId = threadRef?.environmentId ?? activeThreadEnvironmentId; + const urls = useAssetUrls( + environmentId, + viewed.map(({ asset }) => asset.resource), + ); + return viewed.map(({ path, asset }, index) => { + const url = urls[index]; + if (!url) return { path, item: null }; + const src = url + asset.srcFragment; + const name = asset.alt.trim() || "image"; + return { + path, + item: { + src, + name, + actionsSource: assetMediaActionsSource({ + environmentId, + resource: asset.resource, + kind: "image", + name: asset.alt || "image", + src, + workspaceRoot, + }), + }, + }; + }); +} + function ExpandedWorkGroupEntries({ anchorKey, disclosureAnchorKey, entries, + groupedEntries, workspaceRoot, }: { anchorKey: string; disclosureAnchorKey: string; entries: TimelineWorkEntry[]; + groupedEntries: ReadonlyArray; workspaceRoot: string | undefined; }) { const { @@ -164,8 +218,10 @@ function ExpandedWorkGroupEntries({ }); } + const viewedImages = useWorkGroupViewedImages(groupedEntries); const groupView = { state: viewState, + viewedImages, onToggleEntry: (collapsed: boolean, entryId: string) => onToggleWorkEntry(disclosureAnchorKey, collapsed, entryId), onContentReady: (entryId: string) => onWorkEntryContentReady?.(disclosureAnchorKey, entryId), @@ -380,7 +436,7 @@ export function ThinkingTimelineRow({ row }: { row: Extract { - const activeElement = document.activeElement; - if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { + // Focusing the editor on a touch device opens the on-screen keyboard, so the menu keeps its own focus return. + finalFocus: + composerRef && !isCoarsePointer + ? () => { + const activeElement = document.activeElement; + if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { + return false; + } + composerRef.current?.focusAtEnd(); return false; } - composerRef.current?.focusAtEnd(); - return false; - } - : undefined, + : undefined, }; } diff --git a/apps/web/src/components/chat/markdownImageGallery.test.ts b/apps/web/src/components/chat/markdownImageGallery.test.ts new file mode 100644 index 000000000000..88a6171f8a96 --- /dev/null +++ b/apps/web/src/components/chat/markdownImageGallery.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { workGroupImageGallery } from "./markdownImageGallery"; + +const item = (name: string) => ({ src: `https://assets.test/${name}`, name }); + +describe("workGroupImageGallery", () => { + it("pages the clicked image through the group's resolved images", () => { + const clicked = { ...item("second.png"), src: "https://assets.test/second.png#fresh" }; + const viewed = [ + { path: "/repo/first.png", item: item("first.png") }, + { path: "/repo/pending.png", item: null }, + { path: "/repo/second.png", item: item("second.png") }, + { path: "/repo/third.png", item: item("third.png") }, + ]; + expect( + workGroupImageGallery(viewed, "/repo/second.png", { images: [clicked], index: 0 }), + ).toEqual({ images: [item("first.png"), clicked, item("third.png")], index: 1 }); + const single = { images: [clicked], index: 0 }; + expect(workGroupImageGallery(viewed, "/repo/other.png", single)).toBe(single); + }); +}); diff --git a/apps/web/src/components/chat/markdownImageGallery.ts b/apps/web/src/components/chat/markdownImageGallery.ts index ea49e5826be1..61265ac40705 100644 --- a/apps/web/src/components/chat/markdownImageGallery.ts +++ b/apps/web/src/components/chat/markdownImageGallery.ts @@ -47,3 +47,27 @@ export function markdownImageGallery( } return index < 0 ? { images: [selected], index: 0 } : { images, index }; } + +/** + * Widens the single-image preview of an image in an expanded tool row to the images its group + * viewed. Built from the group's entries: its thumbnail strip can be virtualized out of the DOM. + */ +export function workGroupImageGallery( + viewedImages: ReadonlyArray<{ readonly path: string; readonly item: ExpandedImageItem | null }>, + clickedPath: string | null, + preview: ExpandedImagePreview, +): ExpandedImagePreview { + const selected = preview.images[preview.index]; + if (preview.images.length !== 1 || !selected) return preview; + const images: ExpandedImageItem[] = []; + let index = -1; + for (const viewed of viewedImages) { + if (viewed.path === clickedPath) { + index = images.length; + images.push(selected); + } else if (viewed.item) { + images.push(viewed.item); + } + } + return index < 0 ? preview : { images, index }; +} diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index cf64873e2b15..6a298fd6de04 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -24,7 +24,7 @@ import { type LegendListRef } from "@legendapp/list/react"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import type { PendingApproval } from "../../session-logic"; import { type ChatFileAttachment } from "../../types"; -import { ExpandedImagePreview } from "./ExpandedImagePreview"; +import { type ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; import { type AssistantCitationTarget } from "./AssistantCitationSource"; import { type MessagesTimelineRow, type WorkGroupScrollAnchor } from "./MessagesTimeline.logic"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -127,8 +127,15 @@ export interface WorkGroupViewState { expandedEntries: Set; } +/** One image the group's agent viewed; `item` is null until its URL resolves. */ +export interface WorkGroupViewedImage { + readonly path: string; + readonly item: ExpandedImageItem | null; +} + export const WorkGroupViewCtx = createContext<{ state: WorkGroupViewState; + viewedImages: ReadonlyArray; onToggleEntry: (collapsed: boolean, entryId: string) => void; onContentReady?: ((entryId: string) => void) | undefined; } | null>(null); diff --git a/apps/web/src/components/chat/useComposerFocus.ts b/apps/web/src/components/chat/useComposerFocus.ts new file mode 100644 index 000000000000..2dbf82308f45 --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocus.ts @@ -0,0 +1,18 @@ +import type { ComposerHandleRef } from "../../composerHandleContext"; +import { useStableCallback } from "../../hooks/useStableCallback"; +import { isModelPickerOpen } from "../../modelPickerVisibility"; + +// Kept out of ChatView: a compiler-memoized closure there pins its render's scope, which pins the +// previous render's, so every thread copy ChatView showed stayed alive until it unmounted. +export function useComposerFocus(composerRef: ComposerHandleRef) { + const focusComposer = useStableCallback(() => { + composerRef.current?.focusAtEnd(); + }); + const scheduleComposerFocus = useStableCallback(() => { + window.requestAnimationFrame(() => { + // The model picker stays open through its own changes, so it keeps focus. + if (!isModelPickerOpen()) focusComposer(); + }); + }); + return { focusComposer, scheduleComposerFocus }; +} diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 7620bf861102..70ff7e8fd3f0 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -1,3 +1,7 @@ +import { + legacyThreadPullRequestKey, + threadPullRequestKeyOf, +} from "@t3tools/client-runtime/thread-pull-requests"; import { matchComposerEnvironmentItems, matchComposerThreadItems, @@ -327,10 +331,17 @@ export function useComposerMenuItems({ ) { const exactPullRequest = exactPullRequestLookup.data?.number === pullRequestTriggerNumber - ? [exactPullRequestLookup.data] + ? [ + { + ...exactPullRequestLookup.data, + // Only the host: the URL's full repository path would not match the project's selector. + host: legacyThreadPullRequestKey(exactPullRequestLookup.data).host, + }, + ] : []; const matches = /^\d*$/u.test(composerTrigger.query) ? filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries: [...exactPullRequest, ...(pullRequestLookup.data?.entries ?? [])], linked: linkedPullRequests, projectId: pullRequestProjectId, @@ -339,6 +350,7 @@ export function useComposerMenuItems({ limit: COMPOSER_PULL_REQUEST_RESULT_LIMIT, }) : withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: rankPullRequestMatches( (pullRequestLookup.data?.entries ?? []).filter((entry) => { if ( @@ -363,7 +375,7 @@ export function useComposerMenuItems({ limit: COMPOSER_PULL_REQUEST_RESULT_LIMIT, }); return matches.map((pullRequest) => ({ - id: `pull-request:${pullRequest.projectId}:${pullRequest.repository}:${pullRequest.number}`, + id: `pull-request:${pullRequest.projectId}:${threadPullRequestKeyOf(pullRequest)}`, type: "pull-request", pullRequest: { number: pullRequest.number, @@ -384,7 +396,7 @@ export function useComposerMenuItems({ return []; })(); const isLoading = - (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "path" && workspaceEntries.isPending) || ((composerTriggerKind === "skill" || composerTriggerKind === "slash-command") && workspaceSkillsScanning) || (composerTriggerKind === "pull-request" && diff --git a/apps/web/src/components/chat/useLocalDispatchState.ts b/apps/web/src/components/chat/useLocalDispatchState.ts index 977028db17b9..eee86c699d8c 100644 --- a/apps/web/src/components/chat/useLocalDispatchState.ts +++ b/apps/web/src/components/chat/useLocalDispatchState.ts @@ -1,4 +1,4 @@ -import { type MessageId, type RuntimeRequestId } from "@t3tools/contracts"; +import { type EnvironmentId, type MessageId, type RuntimeRequestId } from "@t3tools/contracts"; import { useState } from "react"; import { type ComposerSubmissionIntent } from "../../composer-logic"; import { type SessionPhase, type Thread } from "../../types"; @@ -9,6 +9,7 @@ import { } from "../ChatView.logic"; export function useLocalDispatchState(input: { + environmentId: EnvironmentId; activeThread: Thread | undefined; activeLatestRun: Thread["latestRun"] | null; latestUserMessageId: MessageId | null; @@ -50,6 +51,7 @@ export function useLocalDispatchState(input: { } return createLocalDispatchSnapshot(input.activeThread, { ...options, + environmentId: input.environmentId, latestUserMessageId: input.latestUserMessageId, }); }); diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index da8d5bf7d32f..e357066d1d7d 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -22,7 +22,6 @@ import { useShallow } from "zustand/react/shallow"; import { type DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; -import { reviewCommentContextRecord } from "../../lib/composerContextRecords"; import { buildPendingUserInputAnswers, setPendingUserInputCustomAnswer, @@ -33,6 +32,7 @@ import { carryDisplacedCustomAnswerIntoPrompt, withdrawDisplacedCustomAnswerFromPrompt, derivePendingUserInputProgress, + answerContextRecords, projectPendingUserInputAnswer, threadContextsReferencedByAnswer, } from "../../pendingUserInput"; @@ -300,10 +300,7 @@ export function usePendingThreadRequests(input: { if (userInputResponsesInFlight.current.has(responseKey)) return; const attachmentsByQuestionId = new Map(); const providerAnswers = { ...answers }; - // Diff comments land in the thread draft even when their chip is in an answer. - const reviewCommentRecords = ( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments ?? [] - ).map(reviewCommentContextRecord); + const threadDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); for (const question of pendingInput.questions) { const target = questionAttachmentDraftId( environmentId, @@ -313,10 +310,10 @@ export function usePendingThreadRequests(input: { ); if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; const draft = useComposerDraftStore.getState().getComposerDraft(target); - providerAnswers[question.id] = projectPendingUserInputAnswer(answers[question.id], [ - ...(draft?.threadContexts ?? []), - ...reviewCommentRecords, - ]); + providerAnswers[question.id] = projectPendingUserInputAnswer( + answers[question.id], + answerContextRecords(draft, threadDraft), + ); const attachments = draft ? [...draft.images, ...draft.files] : []; if (attachments.length === 0) continue; const uploaded = getUploadedAttachments({ environmentId, images: attachments }); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 0320dd6d6bb1..3118cd3ec710 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -18,6 +18,7 @@ import { toastManager } from "../ui/toast"; import { act, render } from "../../../test/render"; import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "../../lib/terminalContext"; import { environmentContextRecord, environmentContextReference, @@ -327,6 +328,32 @@ describe("useQueuedRunEdit", () => { ); }); + it("adds terminal context to the queued message being edited", async () => { + const { hook } = setup(); + await using _screen = await render(view(THREAD_A, "queued", hook)); + await beginChangedEdit(hook); + const target = requireHook(hook).draftTarget; + const inserted = useComposerDraftStore.getState().insertTerminalContext( + target, + `changed${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}`, + { + id: "terminal-context-1", + threadId: THREAD_A.threadId, + createdAt: "2026-01-01T00:00:00.000Z", + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 3, + lineEnd: 3, + text: "hello", + }, + 0, + ); + expect(inserted).toBe(true); + expect( + useComposerDraftStore.getState().getComposerDraft(target)?.terminalContexts, + ).toHaveLength(1); + }); + it("keeps an open edit when the user leaves the thread and comes back", async () => { const { hook } = setup(); await using screen = await render(view(THREAD_A, "queued", hook)); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index e5e5c11dff75..0ed3f9228940 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -28,7 +28,7 @@ import { Atom } from "effect/reactivity"; import type { RefObject } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; import { - DraftId, + queuedRunEditDraftId, useComposerDraftStore, type ComposerFileAttachment, type ComposerImageAttachment, @@ -95,10 +95,6 @@ interface QueuedRunEditContent { const isDraftOwnedRecord = (record: ComposerContextRecord) => record.kind === "thread" || record.kind === "environment"; -function queuedEditDraftTarget(routeThreadRef: ScopedThreadRef, runId: RunId) { - return DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`); -} - async function submitQueuedRunEdit(input: { readonly environmentId: EnvironmentId; readonly draftTarget: ComposerThreadTarget; @@ -216,7 +212,7 @@ export function useQueuedRunEdit(input: { const editing = activeThreadId === null ? null : storedEdit; const isSaving = useAtomValue(queuedRunEdits.savingAtom)[editKey] === true; const draftTarget = - editing === null ? threadDraftTarget : queuedEditDraftTarget(routeThreadRef, editing.runId); + editing === null ? threadDraftTarget : queuedRunEditDraftId(routeThreadRef, editing.runId); const imageResources = (editing?.existingAttachments ?? []) .filter((attachment) => attachment.type === "image") @@ -239,12 +235,12 @@ export function useQueuedRunEdit(input: { const clearEditDraft = (runId: RunId) => useComposerDraftStore .getState() - .clearComposerContent(queuedEditDraftTarget(routeThreadRef, runId)); + .clearComposerContent(queuedRunEditDraftId(routeThreadRef, runId)); const beginEdit = useStableCallback((request: EditQueuedRunRequest) => { if (activeThreadId === null) return; if (editing !== null && editing.runId !== request.runId) clearEditDraft(editing.runId); - const target = queuedEditDraftTarget(routeThreadRef, request.runId); + const target = queuedRunEditDraftId(routeThreadRef, request.runId); const sentText = stripAttachmentOnlyBootstrapPrompt(request.text); const context = serverProjection?.messages.find( (message) => message.id === request.messageId, @@ -294,7 +290,7 @@ export function useQueuedRunEdit(input: { // unscoped and names the thread: the user may be anywhere by now. onRunLeftQueue: (_, edit) => { const recovery = recoverQueuedMessageEdit({ - editTarget: queuedEditDraftTarget(routeThreadRef, edit.runId), + editTarget: queuedRunEditDraftId(routeThreadRef, edit.runId), threadTarget: threadDraftTarget, originalText: edit.originalText, removedExistingAttachment: edit.removedExistingAttachment === true, diff --git a/apps/web/src/components/chat/useSystemComposerBanners.tsx b/apps/web/src/components/chat/useSystemComposerBanners.tsx index f172f9e4b7fb..aa9af280da2e 100644 --- a/apps/web/src/components/chat/useSystemComposerBanners.tsx +++ b/apps/web/src/components/chat/useSystemComposerBanners.tsx @@ -177,8 +177,11 @@ export function useSystemComposerBanners(input: SystemComposerBannersInput) { unavailableConnection !== null && (unavailableConnection.phase === "connecting" || unavailableConnection.phase === "reconnecting"); + const awaitingApproval = unavailableConnection?.awaitingApproval === true; const suppressUnavailableBanner = - environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); + environmentReconnecting && + !awaitingApproval && + (updateRunning || !reconnectWarningGraceElapsed); const showUnavailableBanner = activeEnvironmentUnavailableState !== null && unavailableConnection !== null && @@ -189,8 +192,12 @@ export function useSystemComposerBanners(input: SystemComposerBannersInput) { variant: unavailableConnection.phase === "error" ? "error" : "warning", group: "environment", icon: , - title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: activeEnvironment ? ( + title: awaitingApproval + ? `${activeEnvironmentUnavailableState.label} is waiting to approve this device` + : `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, + description: awaitingApproval ? ( + (unavailableConnection.error ?? undefined) + ) : activeEnvironment ? ( ) : undefined, actions: ( diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index 6b66bed80892..cb00e5ab97a5 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,8 +1,9 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon } from "lucide-react"; +import { LogInIcon, RotateCwIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { retryClerkLoad, useClerkLoadFailed } from "./clerkLoadFailure"; import { T3_CONNECT_ACCOUNT_PAGES } from "./T3ConnectAccountPages"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; @@ -49,8 +50,21 @@ function ConfiguredT3ConnectSidebarAvatar() { function ConfiguredT3ConnectSidebarSignIn() { const { isLoaded, isSignedIn } = useAuth(); + const loadFailed = useClerkLoadFailed(); const { openAuthPrompt } = useT3ConnectAuthPrompt(); + if (loadFailed) { + return ( + + + + + Retry T3 Connect + + + + ); + } if (!isLoaded || isSignedIn) return null; return ( diff --git a/apps/web/src/components/clerk/clerkLoadFailure.ts b/apps/web/src/components/clerk/clerkLoadFailure.ts new file mode 100644 index 000000000000..b2fa0077d75c --- /dev/null +++ b/apps/web/src/components/clerk/clerkLoadFailure.ts @@ -0,0 +1,11 @@ +import { useClerk } from "@clerk/react"; + +export function useClerkLoadFailed() { + return useClerk().status === "error"; +} + +export function retryClerkLoad() { + // Clerk caches its failed script load for the life of the page and never + // retries, so a reload is the only recovery. + window.location.reload(); +} diff --git a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx index b87c0d5a88b2..c81c1853f173 100644 --- a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx +++ b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx @@ -2,10 +2,15 @@ import { useClerk } from "@clerk/react"; import { isElectron } from "../../env"; import { resolveClerkSignInProps } from "./authRedirect"; +import { retryClerkLoad } from "./clerkLoadFailure"; export function useT3ConnectAuthPrompt() { const clerk = useClerk(); const openAuthPrompt = () => { + if (clerk.status === "error") { + retryClerkLoad(); + return; + } clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); }; return { openAuthPrompt }; diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 34dfbacc6872..c411fb81be5c 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -460,7 +460,9 @@ export function CloudEnvironmentConnectRows({ className={cn( "mt-1 truncate text-xs", savedConnection?.tone === "error" || - (savedConnection?.tone === "connecting" && savedEnvironment?.connection.error) || + (savedConnection?.tone === "connecting" && + savedEnvironment?.connection.error && + !savedEnvironment.connection.awaitingApproval) || (savedConnection === null && availability === "error") ? "text-destructive" : "text-muted-foreground", diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index 53085ecd50ea..c287b6544b85 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -9,6 +9,7 @@ import { import { isElectron } from "../../env"; import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; import { resolveClerkSignInProps } from "../clerk/authRedirect"; +import { retryClerkLoad, useClerkLoadFailed } from "../clerk/clerkLoadFailure"; import { Button } from "../ui/button"; const invalidLinkMessage = { @@ -22,6 +23,7 @@ export function ConnectCliAuthorizeSurface() { const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); const clerk = useClerk(); const { isLoaded, isSignedIn } = useAuth(); + const loadFailed = useClerkLoadFailed(); const signInOpened = useRef(false); const redirecting = useRef(false); @@ -77,7 +79,13 @@ export function ConnectCliAuthorizeSurface() { : "Sign in to continue authorizing T3 Connect for your CLI." } /> - {isLoaded && !isSignedIn ? ( + {loadFailed ? ( +
+ +
+ ) : isLoaded && !isSignedIn ? (
{operationError ?

{operationError}

: null} {tunnelLimit !== null ? ( diff --git a/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts index 8509eae076c0..3842ae82f285 100644 --- a/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts +++ b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts @@ -27,7 +27,7 @@ export function presentSavedCloudEnvironmentConnection( }; case "reconnecting": return { - buttonLabel: "Reconnecting…", + buttonLabel: connection.awaitingApproval ? "Waiting for approval…" : "Reconnecting…", statusText: connectionStatusText(connection), tone: "connecting", }; diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.browser.test.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.browser.test.tsx index 3547540e8d72..a64cb3cbb399 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.browser.test.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.browser.test.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; +import { cdp, userEvent } from "vite-plus/test/browser"; import "~/index.css"; import { render } from "../../../test/render"; @@ -64,4 +64,145 @@ describe("ReadOnlySourcePreview", () => { document.getSelection()!.setBaseAndExtent(firstText, 4, pre, pre.childNodes.length); expect(copiedText(rows()[0]!)).toBeNull(); }); + + it.each([ + ["a lone carriage return", "\r"], + ["a carriage return and line feed", "\r\n"], + ])("copies the whole of a file whose lines end in %s", async (_name, ending) => { + const contents = text.replaceAll("\n", ending); + await getSyntaxHighlighterPromise("text"); + await using screen = await render( +
+ +
, + ); + const root = screen.container.querySelector("diffs-container")!.shadowRoot!; + const rows = () => root.querySelectorAll("[data-line]"); + await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1")); + + await userEvent.click(rows()[0]!); + await userEvent.keyboard("{ControlOrMeta>}a{/ControlOrMeta}"); + const scroller = screen.container.querySelector(".file-preview-virtualizer")!; + scroller.scrollTop = scroller.scrollHeight; + await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1")); + expect(copiedText(rows()[0]!)).toBe(contents); + }); + + it("copies a partial selection after its rows scroll out of the mounted window", async () => { + await getSyntaxHighlighterPromise("text"); + await using screen = await render( +
+ +
, + ); + const root = screen.container.querySelector("diffs-container")!.shadowRoot!; + const rows = () => root.querySelectorAll("[data-line]"); + await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1")); + + const rowText = (line: number) => + [...rows()].find((row) => row.textContent === `line ${line}`)!.querySelector("span")! + .firstChild!; + document.getSelection()!.setBaseAndExtent(rowText(5), 0, rowText(20), "line 20".length); + const selected = Array.from({ length: 16 }, (_, index) => `line ${index + 5}`).join("\n"); + + const scroller = screen.container.querySelector(".file-preview-virtualizer")!; + scroller.scrollTop = scroller.scrollHeight; + await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1")); + scroller.scrollTop = 0; + await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1")); + + expect(copiedText(rows()[0]!) ?? document.getSelection()!.toString()).toBe(selected); + }); + + it("drops a selection scrolled out of the mounted window when a click lands on its edge", async () => { + await getSyntaxHighlighterPromise("text"); + await using screen = await render( +
+ +
, + ); + const root = screen.container.querySelector("diffs-container")!.shadowRoot!; + const rows = () => root.querySelectorAll("[data-line]"); + await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1")); + + const rowText = (index: number) => rows()[index]!.querySelector("span")!.firstChild!; + document.getSelection()!.setBaseAndExtent(rowText(4), 0, rowText(19), 4); + const scroller = screen.container.querySelector(".file-preview-virtualizer")!; + scroller.scrollTop = scroller.scrollHeight; + await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1")); + expect(copiedText(rows()[0]!)).toContain("line 5\n"); + + // The first mounted row is where the scrolled-out selection is parked, far above the viewport. + rows()[0]!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, composed: true })); + document.getSelection()!.collapse(rowText(0), 0); + expect(copiedText(rows()[0]!)).toBeNull(); + }); + + it("copies a drag that autoscrolls past the edge of the preview", async () => { + await getSyntaxHighlighterPromise("text"); + await using screen = await render( +
+ +
, + ); + const root = screen.container.querySelector("diffs-container")!.shadowRoot!; + const rows = () => root.querySelectorAll("[data-line]"); + await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1")); + + const start = rows()[4]!.querySelector("span")!.getBoundingClientRect(); + const scroller = screen.container.querySelector(".file-preview-virtualizer")!; + const edge = scroller.getBoundingClientRect().bottom; + const frame = window.frameElement?.getBoundingClientRect(); + const mouse = (type: "mousePressed" | "mouseMoved" | "mouseReleased", x: number, y: number) => + cdp().send("Input.dispatchMouseEvent", { + type, + x: x + (frame?.left ?? 0), + y: y + (frame?.top ?? 0), + button: "left", + buttons: type === "mouseReleased" ? 0 : 1, + clickCount: 1, + }); + await mouse("mousePressed", start.left + 1, start.top + 5); + await mouse("mouseMoved", start.left + 30, edge - 5); + // Held below the preview, the pointer is over no row while the browser scrolls the drag on. + await mouse("mouseMoved", start.left + 30, edge + 60); + await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1"), { timeout: 10_000 }); + await mouse("mouseReleased", start.left + 30, edge + 60); + + const copied = copiedText(rows()[0]!)!; + const from = text.indexOf("line 5\n"); + expect(copied).toBe(text.slice(from, from + copied.length)); + // The drag ends in the rows on screen, short of the last mounted one. + const lastLine = 4 + copied.split("\n").length; + expect(lastLine).toBeGreaterThan(50); + expect(lastLine).toBeLessThan(Number(rows()[rows().length - 1]!.getAttribute("data-line"))); + }); + + it("extends a selection with Shift+click after its anchor scrolls out of the mounted window", async () => { + await getSyntaxHighlighterPromise("text"); + await using screen = await render( +
+ +
, + ); + const root = screen.container.querySelector("diffs-container")!.shadowRoot!; + const rows = () => root.querySelectorAll("[data-line]"); + await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1")); + + await userEvent.click(rows()[2]!.querySelector("span")!, { position: { x: 1, y: 5 } }); + const scroller = screen.container.querySelector(".file-preview-virtualizer")!; + scroller.scrollTop = scroller.scrollHeight; + await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1")); + await userEvent.click(rows()[rows().length - 1]!.querySelector("span")!, { + position: { x: 1, y: 5 }, + modifiers: ["Shift"], + }); + + const clicked = document.getSelection()!.getComposedRanges({ shadowRoots: [root] })[0]!; + const lastLine = clicked.endContainer.textContent!; + expect(Number(lastLine.replace("line ", ""))).toBeGreaterThan(250); + expect(copiedText(rows()[0]!)).toBe( + text.slice(text.indexOf("line 3\n"), text.indexOf(`${lastLine}\n`)), + ); + }); }); diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.tsx index 453fc26ecc6a..d9236753e98a 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.tsx @@ -1,5 +1,5 @@ import { File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; -import type { ClipboardEvent } from "react"; +import { type ClipboardEvent, useEffect, useRef, useState } from "react"; import { DiffWorkerPoolProvider } from "~/components/DiffWorkerPoolProvider"; import { useClientSettings } from "~/hooks/useSettings"; @@ -8,31 +8,7 @@ import { resolveDiffThemeName } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { FILE_LINK_REVEAL_UNSAFE_CSS } from "./fileSurfaceChrome"; - -/** Select All spans the file's shadow tree, where the virtualizer mounts only rows near the viewport. */ -function selectsEveryMountedRow(root: ShadowRoot) { - const rows = root.querySelectorAll("[data-line]"); - const first = rows[0]; - const last = rows[rows.length - 1]; - if (!first || !last) return false; - try { - const selected = document.getSelection()?.getComposedRanges({ shadowRoots: [root] })[0]; - if (!selected) return false; - if (!root.contains(selected.startContainer) || !root.contains(selected.endContainer)) { - return false; - } - const range = document.createRange(); - range.setStart(selected.startContainer, selected.startOffset); - range.setEnd(selected.endContainer, selected.endOffset); - // A dragged selection ends inside rows, so it never holds a row's own boundaries. - return ( - range.comparePoint(first, 0) === 0 && range.comparePoint(last, last.childNodes.length) === 0 - ); - } catch { - // Engines without composed ranges keep their own copy. - return false; - } -} +import { SourceSelection } from "./sourceSelection"; export default function ReadOnlySourcePreview(props: { readonly name: string; @@ -45,15 +21,54 @@ export default function ReadOnlySourcePreview(props: { const resolvedTheme = useRenderedAppearance(); const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); const wordWrap = props.wordWrap ?? defaultWordWrap; - const copyWholeFile = (event: ClipboardEvent) => { + const { text, onPostRender } = props; + const wrapper = useRef(null); + const [selection] = useState(() => new SourceSelection()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- follows the document's selection, which only it reports + useEffect(() => { + const root = () => wrapper.current?.querySelector("diffs-container")?.shadowRoot; + const sync = () => { + const shadowRoot = root(); + if (shadowRoot) selection.sync(shadowRoot, text); + }; + // A drag that autoscrolls is usually released outside the preview. + const release = () => { + const shadowRoot = root(); + if (shadowRoot) selection.release(shadowRoot, text); + }; + document.addEventListener("selectionchange", sync); + window.addEventListener("pointerup", release, true); + window.addEventListener("pointercancel", release, true); + return () => { + document.removeEventListener("selectionchange", sync); + window.removeEventListener("pointerup", release, true); + window.removeEventListener("pointercancel", release, true); + }; + }, [selection, text]); + const copySelection = (event: ClipboardEvent) => { const root = event.currentTarget.querySelector("diffs-container")?.shadowRoot; - if (!root || !selectsEveryMountedRow(root)) return; - event.clipboardData.setData("text/plain", props.text); + const copied = root ? selection.copy(root, text) : undefined; + if (copied === undefined) return; + event.clipboardData.setData("text/plain", copied); event.preventDefault(); }; + const drawSelection: NonNullable = (node, instance, phase) => { + if (phase !== "unmount" && node.shadowRoot) selection.render(node.shadowRoot, text); + return onPostRender?.(node, instance, phase); + }; return ( -
+
selection.press(event.button !== 0 || event.shiftKey)} + // Scrolling unmounts rows, and a selection made in the same task has not been reported yet. + onScrollCapture={(event) => { + const root = event.currentTarget.querySelector("diffs-container")?.shadowRoot; + if (root) selection.sync(root, text); + }} + > diff --git a/apps/web/src/components/files/sourceSelection.ts b/apps/web/src/components/files/sourceSelection.ts new file mode 100644 index 000000000000..f88e11482618 --- /dev/null +++ b/apps/web/src/components/files/sourceSelection.ts @@ -0,0 +1,275 @@ +interface Point { + readonly node: Node; + readonly offset: number; +} + +interface Ends { + readonly anchor: number; + readonly focus: number; +} + +/** The rows the virtualizer has mounted, as offsets into the file's text. */ +interface Mounted { + readonly rows: NodeListOf; + readonly firstLine: number; + readonly start: number; + readonly end: number; +} + +/** Lines break where the renderer breaks them: at `\n`, `\r\n`, and a lone `\r`. */ +function lineStarts(text: string) { + const starts = [0]; + for (const match of text.matchAll(/\r\n|\r|\n/g)) starts.push(match.index + match[0].length); + return starts; +} + +function sameRange(a: StaticRange | undefined, b: StaticRange | undefined) { + if (!a || !b) return a === b; + return ( + a.startContainer === b.startContainer && + a.startOffset === b.startOffset && + a.endContainer === b.endContainer && + a.endOffset === b.endOffset + ); +} + +function rowOf(node: Node) { + const element = node instanceof Element ? node : node.parentElement; + return element?.closest("[data-line]") ?? null; +} + +/** + * A text selection in a virtualized file, kept as offsets into the file's text. + * The browser's own selection can only reach mounted rows: an end whose row unmounts + * collapses onto whatever is still there, so the offsets outlive it and are drawn back + * onto the rows after every render. + */ +export class SourceSelection { + #text = ""; + #lineStarts: number[] | undefined; + #ends: Ends | undefined; + #pressed = false; + /** A press that places a caret, where a collapsed selection is the click's and never a kept one. */ + #clicking = false; + /** The browser's selection as a render left it during a press, when nothing was drawn over it. */ + #parked: { readonly range: StaticRange | undefined } | undefined; + + #use(text: string) { + if (text === this.#text) return; + this.#text = text; + this.#lineStarts = undefined; + this.#ends = undefined; + this.#parked = undefined; + } + + get #starts() { + return (this.#lineStarts ??= lineStarts(this.#text)); + } + + #lineEnd(line: number) { + const next = this.#starts[line + 1]; + if (next === undefined) return this.#text.length; + return this.#text.startsWith("\r\n", next - 2) ? next - 2 : next - 1; + } + + #lineAt(offset: number) { + let low = 0; + let high = this.#starts.length - 1; + while (low < high) { + const middle = (low + high + 1) >> 1; + if (this.#starts[middle]! <= offset) low = middle; + else high = middle - 1; + } + return low; + } + + #mounted(root: ShadowRoot): Mounted | undefined { + const rows = root.querySelectorAll("[data-line]"); + const first = rows[0]; + const last = rows[rows.length - 1]; + if (!first || !last) return undefined; + const firstLine = Number(first.dataset.line) - 1; + const start = this.#starts[firstLine]; + if (start === undefined) return undefined; + return { rows, firstLine, start, end: this.#lineEnd(Number(last.dataset.line) - 1) }; + } + + /** `inRow` is false for a point between rows, which is where an unmounted row leaves one. */ + #offsetOf(point: Point, side: "start" | "end", mounted: Mounted) { + const row = rowOf(point.node); + const range = document.createRange(); + if (row) { + const line = Number(row.dataset.line) - 1; + range.setStart(row, 0); + range.setEnd(point.node, point.offset); + const start = this.#starts[line] ?? this.#text.length; + return { + inRow: true, + offset: Math.min(start + range.toString().length, this.#lineEnd(line)), + }; + } + range.setStart(point.node, point.offset); + const rows = [...mounted.rows]; + const near = + side === "start" + ? rows.find((candidate) => range.comparePoint(candidate, 0) >= 0) + : rows.findLast( + (candidate) => range.comparePoint(candidate, candidate.childNodes.length) <= 0, + ); + const offset = near + ? side === "start" + ? this.#starts[Number(near.dataset.line) - 1]! + : this.#lineEnd(Number(near.dataset.line) - 1) + : side === "start" + ? mounted.end + : mounted.start; + return { inRow: false, offset }; + } + + #pointAt(offset: number, mounted: Mounted): Point { + const clamped = Math.max(mounted.start, Math.min(offset, mounted.end)); + const line = this.#lineAt(clamped); + const row = mounted.rows[line - mounted.firstLine]; + if (!row) return { node: mounted.rows[0]!, offset: 0 }; + let column = clamped - this.#starts[line]!; + const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const length = node.nodeValue?.length ?? 0; + if (column <= length) return { node, offset: column }; + column -= length; + } + return { node: row, offset: row.childNodes.length }; + } + + /** + * Reads the browser's selection into the offsets. An end resting on the edge of the + * mounted rows keeps its offset when that offset lies past the edge, and a render + * (`afterRender`) keeps every end its unmounted row left between rows. + */ + #read(root: ShadowRoot, afterRender: boolean) { + const selection = document.getSelection(); + let range: StaticRange | undefined; + try { + range = selection?.getComposedRanges({ shadowRoots: [root] })[0]; + } catch { + // Engines without composed ranges keep their own selection and copy. + this.#ends = undefined; + return undefined; + } + if (range && !(root.contains(range.startContainer) && root.contains(range.endContainer))) { + this.#ends = undefined; + return undefined; + } + const mounted = this.#mounted(root); + if (!mounted) return undefined; + if (!afterRender && this.#parked && sameRange(range, this.#parked.range)) return mounted; + if (!selection || !range) { + if (!afterRender) this.#ends = undefined; + return mounted; + } + const start = this.#offsetOf( + { node: range.startContainer, offset: range.startOffset }, + "start", + mounted, + ); + const end = this.#offsetOf( + { node: range.endContainer, offset: range.endOffset }, + "end", + mounted, + ); + const backward = selection.direction === "backward"; + const previous = this.#ends; + // Select All spans the shadow tree, so it holds every mounted row from outside them. + const holdsEveryRow = + !start.inRow && !end.inRow && start.offset === mounted.start && end.offset === mounted.end; + if (!afterRender && holdsEveryRow) { + this.#ends = { anchor: 0, focus: this.#text.length }; + return mounted; + } + const keeps = (now: typeof start, was: number | undefined) => + was !== undefined && + (now.inRow + ? (now.offset === mounted.start && was < mounted.start) || + (now.offset === mounted.end && was > mounted.end) + : afterRender); + const anchor = backward ? end : start; + const focus = backward ? start : end; + const keepsAnchor = keeps(anchor, previous?.anchor); + const keepsFocus = keeps(focus, previous?.focus); + // A click collapses the selection, and it is a new one unless both ends were already there. + const collapsed = start.offset === end.offset && start.inRow && end.inRow; + if (collapsed && (this.#clicking || !(keepsAnchor && keepsFocus))) { + this.#ends = { anchor: start.offset, focus: start.offset }; + return mounted; + } + this.#ends = { + anchor: keepsAnchor ? previous!.anchor : anchor.offset, + focus: keepsFocus ? previous!.focus : focus.offset, + }; + return mounted; + } + + /** Call when the browser's selection changes. */ + sync(root: ShadowRoot, text: string) { + this.#use(text); + this.#read(root, false); + } + + #draw(root: ShadowRoot, mounted: Mounted | undefined) { + this.#parked = undefined; + if (!mounted || !this.#ends) return; + const anchor = this.#pointAt(this.#ends.anchor, mounted); + const focus = this.#pointAt(this.#ends.focus, mounted); + const selection = document.getSelection(); + const range = selection?.getComposedRanges({ shadowRoots: [root] })[0]; + if (this.#pressed && anchor.node === focus.node && anchor.offset === focus.offset) { + // Chromium stops extending a drag once a script collapses its selection, so a drag whose + // ends are both unmounted keeps what the browser has left of it. + this.#parked = { range }; + return; + } + const [start, end] = selection?.direction === "backward" ? [focus, anchor] : [anchor, focus]; + if ( + range?.startContainer === start.node && + range.startOffset === start.offset && + range.endContainer === end.node && + range.endOffset === end.offset + ) { + return; + } + selection?.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset); + } + + /** Call after rows mount or unmount, to draw the selection onto the rows now there. */ + render(root: ShadowRoot, text: string) { + this.#use(text); + if (this.#ends) this.#draw(root, this.#read(root, true)); + } + + press(extending: boolean) { + this.#pressed = true; + this.#clicking = !extending; + } + + release(root: ShadowRoot, text: string) { + if (!this.#pressed) return; + this.#use(text); + const mounted = this.#read(root, false); + this.#pressed = false; + this.#clicking = false; + this.#draw(root, mounted); + } + + /** The text to copy when the browser's own copy would miss part of it, else `undefined`. */ + copy(root: ShadowRoot, text: string) { + this.#use(text); + const mounted = this.#read(root, false); + if (!mounted || !this.#ends) return undefined; + const start = Math.min(this.#ends.anchor, this.#ends.focus); + const end = Math.max(this.#ends.anchor, this.#ends.focus); + if (start === end) return undefined; + const whole = start === 0 && end === text.length; + if (!whole && start >= mounted.start && end <= mounted.end) return undefined; + return text.slice(start, end); + } +} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 6faae7da2344..18fb969e6003 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -14,6 +14,7 @@ interface MediaVideoPlayerProps { readonly src: string | null; readonly label: string; readonly sourceFailed?: boolean | undefined; + readonly unavailableDetail?: string | undefined; readonly originalUrl?: string | undefined; readonly revision?: string | null | undefined; readonly preload?: "visible" | "metadata" | undefined; @@ -32,6 +33,7 @@ export function MediaVideoPlayer({ src: latestSrc, label, sourceFailed = false, + unavailableDetail, originalUrl, revision = null, preload = "visible", @@ -136,9 +138,9 @@ export function MediaVideoPlayer({ stateClassName, )} > - + - Video unavailable{label ? ` · ${label}` : ""} + {["Video unavailable", label, unavailableDetail].filter(Boolean).join(" · ")} {latestSrc !== null || onRetry ? ( diff --git a/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx index d8099e3907bf..409bbf481570 100644 --- a/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx @@ -67,6 +67,10 @@ vi.mock(import("@clerk/react"), async (original) => ({ ...(await original()), useAuth: (() => state.auth) as never, })); +vi.mock(import("../clerk/clerkLoadFailure"), async (original) => ({ + ...(await original()), + useClerkLoadFailed: () => false, +})); vi.mock(import("../clerk/useT3ConnectAuthPrompt"), async (original) => ({ ...(await original()), useT3ConnectAuthPrompt: (() => ({ openAuthPrompt: () => {} })) as never, diff --git a/apps/web/src/components/onboarding/WelcomePage.tsx b/apps/web/src/components/onboarding/WelcomePage.tsx index 48171ccf9445..4696cb9f1cc9 100644 --- a/apps/web/src/components/onboarding/WelcomePage.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.tsx @@ -48,6 +48,7 @@ import { useCloudLinkState } from "../../cloud/cloudLinkState"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { workspaceAreaProps } from "../../lib/workspaceFocus"; +import { retryClerkLoad, useClerkLoadFailed } from "../clerk/clerkLoadFailure"; import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { completeOnboarding } from "../../onboarding/firstRun"; import { @@ -961,6 +962,7 @@ function PhoneStep({ step, children }: { readonly step: number; readonly childre function PhoneConnectLink({ environmentId }: { readonly environmentId: EnvironmentId }) { const { isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const loadFailed = useClerkLoadFailed(); const { openAuthPrompt } = useT3ConnectAuthPrompt(); const linkState = useCloudLinkState(environmentId); const refreshLinkState = useEffectEvent(linkState.refresh); @@ -1029,9 +1031,15 @@ function PhoneConnectLink({ environmentId }: { readonly environmentId: Environme

Sign in to T3 Connect here, then in the app with the same account.

- + {loadFailed ? ( + + ) : ( + + )} ); } diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 90175cd0b425..8008990cd283 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -32,6 +32,7 @@ interface Props { onBack: () => void; onForward: () => void; onRefresh: () => void; + onStopLoading: () => void; onSubmit: (url: string) => void; onOpenInBrowser?: (() => void) | undefined; onCapture?: ((record: boolean) => void) | undefined; @@ -61,6 +62,7 @@ export function PreviewChromeRow({ onBack, onForward, onRefresh, + onStopLoading, onSubmit, onOpenInBrowser, onCapture, @@ -135,7 +137,7 @@ export function PreviewChromeRow({
diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 6eb6d0ba9107..f7cb3082491e 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -334,6 +334,11 @@ export function PreviewView({ else if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId); }; + const handleStopLoading = () => { + if (isServerTab) serverSurfaceRef.current?.stopLoading(); + else if (previewBridge && runtimeTabId) void previewBridge.stopLoading(runtimeTabId); + }; + const adjustServerTab = async (change: Omit) => { if (!tabId) return; const result = await adjust({ @@ -984,6 +989,7 @@ export function PreviewView({ onBack={handleBack} onForward={handleForward} onRefresh={handleRefresh} + onStopLoading={handleStopLoading} onSubmit={(next) => void handleSubmitUrl(next)} onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} onCapture={previewBridge && tabId && !isServerTab ? handleCapture : undefined} diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx new file mode 100644 index 000000000000..9bb9e746ea12 --- /dev/null +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx @@ -0,0 +1,121 @@ +import "~/index.css"; + +import { RegistryContext } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type DeviceServiceState, type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { + type PreviewMiniPlayerSource, + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "../../previewMiniPlayerStore"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { deviceEnvironment } from "../../state/device"; +import { ChatCanvas } from "../chat/ChatCanvas"; +import { createChatCanvasStore } from "../chat/canvasStore"; +import { deviceStreamScreenAtom } from "../device/deviceStreamSession"; +import { ThreadPreviewMiniPlayer, useThreadPreviewCanvasSource } from "./ThreadPreviewMiniPlayer"; + +vi.mock(import("../device/DeviceStreamView"), () => ({ + DeviceStreamView: (({ viewId }: { viewId: string }) => ( +
+ )) as never, +})); + +const threadRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1")); +const phone: PreviewMiniPlayerSource = { + kind: "device", + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", +}; + +function Harness() { + const [store] = useState(createChatCanvasStore); + const miniPlayer = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), + ); + const preview = useThreadPreviewCanvasSource(threadRef, miniPlayer); + return ( + + {miniPlayer ? ( + + ) : null} + + ); +} + +it("holds its frame through a source change and stops when the gesture is interrupted", async () => { + using _state = vi + .spyOn(deviceEnvironment, "state") + .mockReturnValue(Atom.make>(AsyncResult.initial())); + using _store = { + [Symbol.dispose]: () => usePreviewMiniPlayerStore.getState().removeThread(threadRef), + }; + usePreviewMiniPlayerStore.getState().open(threadRef, phone); + const container = document.createElement("div"); + container.style.cssText = "display:flex;position:fixed;inset:0;width:1200px;height:800px"; + await using screen = await render( + + + , + { container }, + ); + const player = screen.container.querySelector("[data-preview-mini-player]")!; + const handle = (direction: string) => + player.querySelector(`[data-preview-mini-player-resize="${direction}"]`)!; + const rect = () => { + const { left, top, right, bottom } = player.getBoundingClientRect(); + return { left, top, right, bottom }; + }; + const point = (target: HTMLElement, type: string, x: number, y: number, buttons: number) => + act(() => + target.dispatchEvent( + new PointerEvent(type, { + pointerId: 1, + pointerType: "mouse", + isPrimary: true, + button: type === "pointermove" ? -1 : 0, + buttons, + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }), + ), + ); + const viewId = screen.getByTestId("stream").element().getAttribute("data-view-id")!; + const portrait = rect(); + + // Held without moving while the device rotates: the frame waits for the release. + await point(handle("northwest"), "pointerdown", portrait.left, portrait.top, 1); + await act(() => + appAtomRegistry.set(deviceStreamScreenAtom(viewId), { + width: 1_000, + height: 2_000, + orientation: "landscape_left", + }), + ); + expect(rect()).toEqual(portrait); + + // The release never arrived, so the next buttonless move ends the gesture where it was held. + await point(handle("northwest"), "pointermove", portrait.left - 200, portrait.top - 200, 0); + const landscape = rect(); + expect({ right: landscape.right, bottom: landscape.bottom }).toEqual({ + right: portrait.right, + bottom: portrait.bottom, + }); + expect(landscape.right - landscape.left).toBe(2 * (landscape.bottom - landscape.top)); + await point(handle("northwest"), "pointermove", portrait.left - 300, portrait.top - 300, 1); + expect(rect()).toEqual(landscape); + + await point(handle("west"), "pointerdown", landscape.left, landscape.top + 10, 1); + await act(() => window.dispatchEvent(new Event("blur"))); + await point(handle("west"), "pointermove", landscape.left - 100, landscape.top + 10, 1); + expect(rect()).toEqual(landscape); +}); diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 8e6c8fc42068..3e4dba8d879c 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -6,6 +6,7 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, + useEffectEvent, useRef, useState, } from "react"; @@ -59,28 +60,19 @@ import { previewBridge } from "./previewBridge"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { showPreviewPopup } from "./showPreviewPopup"; import { - clampPreviewMiniPlayerPosition, + type PreviewMiniPlayerGesture, + settlePreviewMiniPlayerResize, + stepPreviewMiniPlayerGesture, +} from "./previewMiniPlayerGesture"; +import { PREVIEW_MINI_PLAYER_CORNER_RADIUS, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, - resizePreviewMiniPlayer, resolveDeviceMiniPlayerCornerRadius, resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; -interface PointerGesture { - readonly pointerId: number; - readonly pointerType: string; - readonly pointerX: number; - readonly pointerY: number; - readonly frame: PreviewMiniPlayerFrame; - readonly direction: BrowserViewportResizeDirection | null; - moved: boolean; -} - -const HANDLE_TAP_SLOP_PX = 6; - interface Props { readonly threadRef: ScopedThreadRef; readonly miniPlayer: PreviewMiniPlayerState; @@ -111,6 +103,13 @@ const useStreamedSourceSize = create<{ readonly viewport: PreviewStreamViewport; } | null>(() => null); +// The source size a resize in progress lays the frame out from, so a source that changes shape +// under a held handle cannot resize the player by itself. Same single slot as above. +const useHeldSourceSize = create<{ + readonly key: string; + readonly source: PreviewMiniPlayerSize; +} | null>(() => null); + const NO_DEVICE_SCREEN_ATOM = Atom.make(null).pipe( Atom.withLabel("device-stream-screen:none"), ); @@ -178,6 +177,11 @@ export function useThreadPreviewCanvasSource( ? deviceStreamScreenAtom(miniPlayerDeviceViewId(threadRef, source)) : NO_DEVICE_SCREEN_ATOM, ); + const heldSource = useHeldSourceSize((state) => + threadRef && sourceKey !== null && state?.key === streamedSourceKey(threadRef, sourceKey) + ? state.source + : null, + ); if (!miniPlayer || !source || sourceKey === null) return null; if (source.kind === "browser" && !browser.snapshot) return null; return { @@ -186,9 +190,10 @@ export function useThreadPreviewCanvasSource( position: miniPlayer.position, lastInteraction: miniPlayer.lastInteraction, source: - source.kind === "browser" + heldSource ?? + (source.kind === "browser" ? browser.sourceSize - : resolveDeviceMiniPlayerSourceSize(source.platform, screen), + : resolveDeviceMiniPlayerSourceSize(source.platform, screen)), }; } @@ -446,7 +451,8 @@ function MiniPlayerShell({ readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; }) { const canvas = useChatCanvas(); - const gestureRef = useRef(null); + const gestureRef = useRef(null); + const captureRef = useRef(null); // Touch toggles the pill after the tap's click so that click cannot land on the newly opened pill. const [pillOpen, setPillOpen] = useState(false); const handleTappedRef = useRef(false); @@ -463,6 +469,7 @@ function MiniPlayerShell({ }, [pillOpen]); const container = canvas?.container ?? null; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); + const heldSourceKey = streamedSourceKey(threadRef, sourceKey); const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; @@ -472,11 +479,54 @@ function MiniPlayerShell({ usePreviewMiniPlayerStore.getState().close(threadRef); }; + /** Every way a gesture stops comes through here; returns the gesture it ended. */ + const finishGesture = () => { + const gesture = gestureRef.current; + if (!gesture) return null; + gestureRef.current = null; + handleTappedRef.current = false; + const handle = captureRef.current; + captureRef.current = null; + if (handle?.hasPointerCapture(gesture.pointerId)) { + handle.releasePointerCapture(gesture.pointerId); + } + if (gesture.direction === null) return gesture; + useHeldSourceSize.setState((state) => (state?.key === heldSourceKey ? null : state), true); + const settled = + frame && container + ? settlePreviewMiniPlayerResize({ + frame, + direction: gesture.direction, + held: gesture.source, + source: sourceSize, + container, + }) + : null; + if (settled) { + usePreviewMiniPlayerStore + .getState() + .resize(threadRef, sourceKey, settled.width, { x: settled.x, y: settled.y }); + } + return gesture; + }; + const endInterruptedGesture = useEffectEvent(() => { + finishGesture(); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window blur ends a gesture whose release went to another window, and unmount ends one in progress + useEffect(() => { + window.addEventListener("blur", endInterruptedGesture); + return () => { + window.removeEventListener("blur", endInterruptedGesture); + useHeldSourceSize.setState((state) => (state?.key === heldSourceKey ? null : state), true); + }; + }, [heldSourceKey]); + const beginGesture = ( event: ReactPointerEvent, direction: BrowserViewportResizeDirection | null, ) => { if (event.button !== 0 || !frame) return; + finishGesture(); gestureRef.current = { pointerId: event.pointerId, pointerType: event.pointerType, @@ -484,8 +534,13 @@ function MiniPlayerShell({ pointerY: event.clientY, frame, direction, + source: sourceSize, moved: false, }; + captureRef.current = event.currentTarget; + if (direction !== null) { + useHeldSourceSize.setState({ key: heldSourceKey, source: sourceSize }, true); + } event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); @@ -493,44 +548,39 @@ function MiniPlayerShell({ const handlePointerMove = (event: ReactPointerEvent) => { const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId || !container) return; - const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; - if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; - gesture.moved = true; + if (!gesture || !container) return; + const step = stepPreviewMiniPlayerGesture(gesture, event, container); + if (step.kind === "ignore") return; + if (step.kind === "end") { + finishGesture(); + return; + } + if (!gesture.moved) gestureRef.current = { ...gesture, moved: true }; const store = usePreviewMiniPlayerStore.getState(); - if (gesture.direction === null) { - store.move( - threadRef, - sourceKey, - clampPreviewMiniPlayerPosition( - { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, - container, - gesture.frame, - ), - ); + if (step.kind === "move") { + store.move(threadRef, sourceKey, step.position); return; } - const next = resizePreviewMiniPlayer({ - start: gesture.frame, - direction: gesture.direction, - delta, - source: sourceSize, - container, - }); - store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); + store.resize(threadRef, sourceKey, step.frame.width, { x: step.frame.x, y: step.frame.y }); }; const endGesture = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (gesture?.pointerId !== event.pointerId) return; - gestureRef.current = null; + if (gestureRef.current?.pointerId !== event.pointerId) return; + const gesture = finishGesture(); handleTappedRef.current = event.type === "pointerup" && - gesture.direction === null && + gesture?.direction === null && gesture.pointerType === "touch" && !gesture.moved; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); + }; + + // Capture can go without a release or cancel: the handle is replaced, or the platform takes it. + const handleLostCapture = (event: ReactPointerEvent) => { + if ( + gestureRef.current?.pointerId === event.pointerId && + captureRef.current === event.currentTarget + ) { + finishGesture(); } }; @@ -558,6 +608,7 @@ function MiniPlayerShell({ onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onLostPointerCapture={handleLostCapture} onClick={() => { if (!handleTappedRef.current) return; handleTappedRef.current = false; @@ -634,6 +685,7 @@ function MiniPlayerShell({ onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onLostPointerCapture={handleLostCapture} /> ))} diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 005d44d9468c..9acfd0eb6c05 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -97,6 +97,30 @@ describe("openPreviewSession", () => { }); }, ); + + it.each(["new tab", "link"] as const)( + "opens a server tab from a %s with the default zoom and appearance", + async (entryPoint) => { + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 0.8, + browserDefaultAppearance: "dark", + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + if (entryPoint === "link") { + vi.spyOn(previewRuntime, "previewRuntimeFor").mockReturnValue("server"); + await openUrlInPreview({ openPreview, threadRef, url: "https://t3.chat/" }); + } else { + await openPreviewSession({ openPreview, threadRef, runtime: "server" }); + } + + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: expect.objectContaining({ runtime: "server", zoomFactor: 0.8, colorScheme: "dark" }), + }); + }, + ); }); describe("openUrlInPreview from a link", () => { diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 99dc6c3e572c..6c372888de5a 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -45,7 +45,9 @@ export async function openPreviewSession( ...(input.url === undefined ? {} : { url: input.url }), viewport: input.viewport ?? defaults.viewport, profileId: input.profileId ?? defaults.profileId, - ...(runtime === undefined || runtime === "desktop" ? {} : { runtime }), + ...(runtime === undefined || runtime === "desktop" + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 069a5782e7fd..ab11752a66fe 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -3,6 +3,8 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import * as previewRuntime from "~/browser/previewRuntime"; import { openTerminalLinkInPreview } from "./openTerminalLinkInPreview"; vi.mock("~/previewStateStore", () => ({ @@ -36,6 +38,8 @@ vi.mock("~/browser/browserLinkTarget", () => ({ const hydratedDefaults = { viewport: { _tag: "fixed", width: 1280, height: 720 } as const, profileId: "work", + zoomFactor: 0.8, + appearance: "dark", }; const threadRef = { @@ -69,4 +73,24 @@ describe("openTerminalLinkInPreview", () => { expect(reportError).not.toHaveBeenCalled(); expect(fallbackToBrowser).not.toHaveBeenCalled(); }); + + it("opens a server tab with the default zoom and appearance", async () => { + vi.spyOn(previewRuntime, "previewRuntimeFor").mockReturnValue("server"); + const openPreview = vi.fn(async () => + AsyncResult.failure(Cause.interrupt()), + ); + + await openTerminalLinkInPreview({ + url: "https://t3.chat/", + threadRef, + openPreview, + fallbackToBrowser: vi.fn(), + forceBrowser: false, + }); + + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: expect.objectContaining({ runtime: "server", zoomFactor: 0.8, colorScheme: "dark" }), + }); + }); }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index d160c1ee7ea6..867cc528f9ae 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -64,7 +64,9 @@ export async function openTerminalLinkInPreview( url: input.url, viewport: defaults.viewport, profileId: defaults.profileId, - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts new file mode 100644 index 000000000000..1d9f9035ebf2 --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveChatCanvasLayout } from "../chat/chatCanvasLayout"; +import { + type PreviewMiniPlayerGesture, + settlePreviewMiniPlayerResize, + stepPreviewMiniPlayerGesture, +} from "./previewMiniPlayerGesture"; + +const container = { width: 1_440, height: 900 }; +const frame = { x: 996, y: 433, width: 268, height: 179 }; +const gesture: PreviewMiniPlayerGesture = { + pointerId: 1, + pointerType: "mouse", + pointerX: 996, + pointerY: 433, + frame, + direction: "northwest", + source: { width: 1_200, height: 800 }, + moved: true, +}; +const pointer = { pointerId: 1, buttons: 1, clientX: 936, clientY: 393 }; + +describe("stepPreviewMiniPlayerGesture", () => { + it("ends on a move without the primary button instead of following the hover", () => { + expect(stepPreviewMiniPlayerGesture(gesture, pointer, container).kind).toBe("resize"); + expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, buttons: 0 }, container)).toEqual({ + kind: "end", + }); + expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, buttons: 2 }, container)).toEqual({ + kind: "end", + }); + expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, pointerId: 2 }, container)).toEqual({ + kind: "ignore", + }); + }); + + it("resizes at the shape the gesture started with", () => { + const step = stepPreviewMiniPlayerGesture(gesture, pointer, container); + expect(step).toEqual({ kind: "resize", frame: { x: 936, y: 393, width: 328, height: 219 } }); + }); +}); + +describe("settlePreviewMiniPlayerResize", () => { + it("keeps the corner opposite the handle when the source changed shape mid-resize", () => { + const source = { width: 1_200, height: 1_000 }; + const settled = settlePreviewMiniPlayerResize({ + frame, + direction: "northwest", + held: gesture.source, + source, + container, + }); + expect(settled).toEqual({ x: 1_018, y: 407, width: 246, height: 205 }); + expect( + resolveChatCanvasLayout({ + container, + preview: { + key: "browser:tab", + width: settled!.width, + position: settled, + source, + lastInteraction: "resize", + }, + }).frame, + ).toEqual(settled); + expect( + settlePreviewMiniPlayerResize({ + frame, + direction: "northwest", + held: gesture.source, + source: { width: 600, height: 400 }, + container, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts new file mode 100644 index 000000000000..6c5892eab73f --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts @@ -0,0 +1,93 @@ +import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; +import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; + +import { + clampPreviewMiniPlayerPosition, + type PreviewMiniPlayerFrame, + resizePreviewMiniPlayer, +} from "./previewMiniPlayerLayout"; + +const HANDLE_TAP_SLOP_PX = 6; + +export interface PreviewMiniPlayerGesture { + readonly pointerId: number; + readonly pointerType: string; + readonly pointerX: number; + readonly pointerY: number; + readonly frame: PreviewMiniPlayerFrame; + /** Null drags the player; a direction resizes it from that handle. */ + readonly direction: BrowserViewportResizeDirection | null; + /** The source size a resize keeps the frame's shape to until it ends. */ + readonly source: PreviewMiniPlayerSize; + readonly moved: boolean; +} + +interface GesturePointer { + readonly pointerId: number; + readonly buttons: number; + readonly clientX: number; + readonly clientY: number; +} + +export type PreviewMiniPlayerGestureStep = + | { readonly kind: "ignore" } + | { readonly kind: "end" } + | { readonly kind: "move"; readonly position: PreviewMiniPlayerPosition } + | { readonly kind: "resize"; readonly frame: PreviewMiniPlayerFrame }; + +/** What a pointer move does to the gesture in progress. */ +export function stepPreviewMiniPlayerGesture( + gesture: PreviewMiniPlayerGesture, + pointer: GesturePointer, + container: PreviewMiniPlayerSize, +): PreviewMiniPlayerGestureStep { + if (pointer.pointerId !== gesture.pointerId) return { kind: "ignore" }; + // A release the handle never saw (over a native view, another window) leaves only this trace. + if ((pointer.buttons & 1) === 0) return { kind: "end" }; + const delta = { x: pointer.clientX - gesture.pointerX, y: pointer.clientY - gesture.pointerY }; + if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) { + return { kind: "ignore" }; + } + if (gesture.direction === null) { + return { + kind: "move", + position: clampPreviewMiniPlayerPosition( + { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, + container, + gesture.frame, + ), + }; + } + return { + kind: "resize", + frame: resizePreviewMiniPlayer({ + start: gesture.frame, + direction: gesture.direction, + delta, + source: gesture.source, + container, + }), + }; +} + +/** + * The frame a finished resize leaves when the source changed shape under it: the new shape, with + * the edges opposite the handle where the gesture left them. Null when the shape is unchanged. + */ +export function settlePreviewMiniPlayerResize(input: { + readonly frame: PreviewMiniPlayerFrame; + readonly direction: BrowserViewportResizeDirection; + readonly held: PreviewMiniPlayerSize; + readonly source: PreviewMiniPlayerSize; + readonly container: PreviewMiniPlayerSize; +}): PreviewMiniPlayerFrame | null { + const { frame, direction, held, source, container } = input; + if (held.width * source.height === source.width * held.height) return null; + return resizePreviewMiniPlayer({ + start: frame, + direction, + delta: { x: 0, y: 0 }, + source, + container, + }); +} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 212da0d088fe..1b95a1a563aa 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -305,7 +305,7 @@ export function ReviewThreadCard({ contentEditable={false} onPointerDown={(event) => event.stopPropagation()} > -
+
{thread.isResolved ? ( ) : ( diff --git a/apps/web/src/components/settings/ConnectDeviceApprovalSettings.tsx b/apps/web/src/components/settings/ConnectDeviceApprovalSettings.tsx new file mode 100644 index 000000000000..af107d186a51 --- /dev/null +++ b/apps/web/src/components/settings/ConnectDeviceApprovalSettings.tsx @@ -0,0 +1,163 @@ +import type { AuthConnectDevice, EnvironmentId } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import * as DateTime from "effect/DateTime"; +import { useState } from "react"; + +import type { ServerPairingLinkRecord } from "~/environments/primary"; +import { authEnvironment } from "~/state/auth"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + connectDeviceName, + describeConnectDeviceClient, + formatAccessTimestamp, +} from "./ConnectionsSettings.logic"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { SettingsSection } from "./settingsLayout"; + +function reportFailure(title: string, result: Parameters[0]) { + 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 ConnectDeviceApprovalSettings({ + environmentId, + requests, + devices, + canWrite, + requestRevoke, +}: { + readonly environmentId: EnvironmentId; + readonly requests: ReadonlyArray; + readonly devices: ReadonlyArray; + readonly canWrite: boolean; + /** Revokes now, or first asks to turn approval on when it is off. */ + readonly requestRevoke: (title: string, revoke: () => Promise) => void; +}) { + const approve = useAtomCommand(authEnvironment.approveConnectDevice, { reportFailure: false }); + const deny = useAtomCommand(authEnvironment.revokePairingLink, { reportFailure: false }); + const revoke = useAtomCommand(authEnvironment.revokeConnectDevice, { reportFailure: false }); + const [busyId, setBusyId] = useState(null); + + const run = async (id: string, action: () => Promise) => { + setBusyId(id); + await action(); + setBusyId(null); + }; + + return ( + + {requests.map((request) => { + const connectRequest = request.connectDeviceRequest; + if (!connectRequest) return null; + const busy = busyId === request.id; + return ( +
+
+
+

+ Waiting for approval ·{" "} + {connectRequest.verificationCode} +

+

+ {describeConnectDeviceClient(connectRequest)}. Approve only if the device in front + of you shows this code. +

+
+
+ + +
+
+
+ ); + })} + {devices.map((device) => { + const busy = busyId === device.proofKeyThumbprint; + return ( +
+
+
+

+ {describeConnectDeviceClient(device)} +

+

+ {device.verificationCode} + · + Trusted since {formatAccessTimestamp(DateTime.formatIso(device.approvedAt))} +

+
+
+ +
+
+
+ ); + })} + {devices.length === 0 && requests.length === 0 ? ( +
+

+ No device has connected through T3 Connect yet. +

+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index d3716ae993a9..28d3b8f2df12 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -9,6 +9,7 @@ import { } from "~/environments/primary"; import { type AdvertisedEndpoint, + type AuthClientMetadata, type AuthClientSession, type AuthGrantScope, type AuthPairingLink, @@ -467,3 +468,24 @@ export function resolveAccessEnvironmentId(input: { ?.environmentId ?? null ); } + +/** A short name for a T3 Connect device: its label, or what it runs on. */ +export function connectDeviceName({ + client, +}: { + readonly client?: AuthClientMetadata | undefined; +}): string { + return client?.label ?? client?.os ?? client?.browser ?? "this device"; +} + +/** How a T3 Connect device describes itself. It can say anything, so the code is what to trust. */ +export function describeConnectDeviceClient({ + client, +}: { + readonly client?: AuthClientMetadata | undefined; +}): string { + const bits = [client?.label, client?.os, client?.browser, client?.ipAddress].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + return bits.length > 0 ? [...new Set(bits)].join(" · ") : "Unknown device"; +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index da529e44c351..9d309a8b90fa 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -29,6 +29,7 @@ import * as Cause from "effect/Cause"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { applyWslEnableSelection, + connectDeviceName, isWslSettingsRowVisible, missingTailscaleEndpointDescription, resolveLocalVersionStatusLabel, @@ -40,6 +41,7 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL import { ManagedByOrganization } from "../ManagedPolicy"; import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; import { searchableSetting } from "./settingsSearch"; +import { ConnectDeviceApprovalSettings } from "./ConnectDeviceApprovalSettings"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { LinkedEnvironmentsSettings } from "./LinkedEnvironmentsSettings"; @@ -106,6 +108,7 @@ import { usePrimaryEnvironment, } from "~/state/environments"; import { requestConfirmDialog } from "~/confirmDialog"; +import { useUpdateEnvironmentSettings } from "~/hooks/useSettings"; import { useAtomCommand } from "../../state/use-atom-command"; import { environmentIdsKey } from "~/state/environmentAtomList"; import { environmentUpdateStatesAtom, serverEnvironment } from "~/state/server"; @@ -139,7 +142,13 @@ import { SavedBackendListRow, } from "./SavedEnvironmentRows"; import { AddEnvironmentDialog } from "./AddEnvironmentDialog"; +import { + type ConnectDeviceRevokeRequest, + RevokeConnectDeviceDialog, +} from "./RevokeConnectDeviceDialog"; +// Sessions T3 Connect minted for a device signed in to the linked account. +const CLOUD_CONNECT_SESSION_SUBJECT = "cloud-connect"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; @@ -706,7 +715,58 @@ function ConnectionsSettingsContent({ if (confirmed === true) await removeSavedBackend(environment); }; - const visibleDesktopPairingLinks = desktopPairingLinks; + // T3 Connect devices waiting for approval have their own list. + const visibleDesktopPairingLinks = desktopPairingLinks.filter( + (pairingLink) => pairingLink.connectDeviceRequest === undefined, + ); + const connectDeviceRequests = desktopPairingLinks.filter( + (pairingLink) => pairingLink.connectDeviceRequest !== undefined, + ); + // Older servers omit the list; they cannot hold devices for approval. + const connectDevices = + authAccessChanges.data?.type === "snapshot" + ? (authAccessChanges.data.payload.connectDevices ?? null) + : null; + const updateAccessSettings = useUpdateEnvironmentSettings(accessEnvironmentId); + const [pendingConnectRevoke, setPendingConnectRevoke] = + useState(null); + // With approval off a revoked T3 Connect device signs straight back in, so offer to turn it + // on. Older servers have no such setting, and revoking there goes ahead as before. + const connectApprovalOff = + accessEnvironment?.serverConfig?.settings.requireConnectDeviceApproval === false; + const requestConnectRevoke = (title: string, revoke: () => Promise) => { + if (connectApprovalOff && canWriteAccess) setPendingConnectRevoke({ title, revoke }); + else void revoke(); + }; + const requireConnectApproval = () => + new Promise((resolve) => + updateAccessSettings( + { requireConnectDeviceApproval: true }, + { onSaved: () => resolve(true), onFailed: () => resolve(false) }, + ), + ); + const requestRevokeClientSession = (sessionId: ServerClientSessionRecord["sessionId"]) => { + const session = desktopClientSessions.find((candidate) => candidate.sessionId === sessionId); + if (session?.subject === CLOUD_CONNECT_SESSION_SUBJECT) { + requestConnectRevoke(`Revoke ${connectDeviceName(session)}?`, () => + handleRevokeDesktopClientSession(sessionId), + ); + } else { + void handleRevokeDesktopClientSession(sessionId); + } + }; + const requestRevokeOtherClients = () => { + const revokesConnectDevices = + (connectDevices?.length ?? 0) > 0 || + desktopClientSessions.some( + (session) => !session.current && session.subject === CLOUD_CONNECT_SESSION_SUBJECT, + ); + if (revokesConnectDevices) { + requestConnectRevoke("Revoke all other clients?", handleRevokeOtherDesktopClients); + } else { + void handleRevokeOtherDesktopClients(); + } + }; const tailscaleHttpsEndpoint = desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null; const visibleDesktopNetworkAdvertisedEndpoints = isLocalBackendNetworkAccessible ? desktopAdvertisedEndpoints.filter((endpoint) => !isTailscaleHttpsEndpoint(endpoint)) @@ -1049,7 +1109,7 @@ function ConnectionsSettingsContent({ revokingPairingLinkId={revokingDesktopPairingLinkId} revokingClientSessionId={revokingDesktopClientSessionId} onRevokePairingLink={handleRevokeDesktopPairingLink} - onRevokeClientSession={handleRevokeDesktopClientSession} + onRevokeClientSession={requestRevokeClientSession} canRevoke={canWriteAccess} /> ); @@ -1241,6 +1301,14 @@ function ConnectionsSettingsContent({ environmentId={accessEnvironmentId} canReadRelay={canReadRelay} canManageRelay={canManageRelay} + connectDeviceApproval={ + accessServerConfig?.settings.requireConnectDeviceApproval === undefined + ? null + : { + required: accessServerConfig.settings.requireConnectDeviceApproval, + canManage: canWriteAccess, + } + } /> ) : null} {accessEnvironment ? ( @@ -1287,7 +1355,7 @@ function ConnectionsSettingsContent({ : null } isRevokingOtherClients={isRevokingOtherDesktopClients} - onRevokeOtherClients={handleRevokeOtherDesktopClients} + onRevokeOtherClients={requestRevokeOtherClients} /> ) : undefined } @@ -1308,6 +1376,22 @@ function ConnectionsSettingsContent({ ) : null} + {canReadAccess && + connectDevices !== null && + (connectDeviceRequests.length > 0 || connectDevices.length > 0) ? ( + + ) : null} + setPendingConnectRevoke(null)} + requireApproval={requireConnectApproval} + /> { diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index 4cf6997a571c..2014a1db6e35 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -103,7 +103,7 @@ function LinkedEnvironmentRow({ const [busy, setBusy] = useState(false); const forget = async () => { const confirmed = await (requestConfirmDialog( - `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Connections to end the session there too.`, + `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Sharing & access to end the session there too.`, { variant: "destructive" }, ) ?? Promise.resolve(false)); if (!confirmed) return; @@ -273,8 +273,8 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi Link an environment - Agents here can then launch, message and wait on threads there. It appears in that - environment's Connections, where it can be revoked. + Agents here can then launch, message and wait on threads there. It appears under + Authorized clients in that environment's Sharing & access, where it can be revoked. @@ -332,9 +332,7 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi value={pairingCode} onChange={(event) => setPairingCode(event.target.value)} placeholder={ - machine === null - ? "From that environment's Connections, or t3 pair there" - : "Paste the code" + machine === null ? "From Sharing & access there, or t3 pair" : "Paste the code" } disabled={busy} autoComplete="one-time-code" @@ -343,8 +341,8 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi /> {machine !== null ? ( - Create a link on {machine.label}: Settings → Connections → Create link, then - paste its code here. + Create a link on {machine.label}: Settings → Sharing & access → Authorized + clients → Create link, then paste its code here. ) : null} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 957682083232..0d505c3250f1 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -132,7 +132,8 @@ vi.mock(import("../files/projectFilesQueryState"), async (original) => ({ getProjectFileQueryAtom: () => missingProjectFile, })); -import { useComposerDraftStore } from "../../composerDraftStore"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { assetEnvironment } from "../../state/assets"; import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; @@ -418,6 +419,37 @@ describe("project settings permissions", () => { expect(screen.router.state.location.href).toBe(initialHref); }); + it("moves a draft on the removed checkout to a remaining checkout of the group", async () => { + setup(); + const draftId = DraftId.make("draft-handed-off"); + const [primaryProject, remoteProject] = state.projects; + const drafts = useComposerDraftStore.getState(); + drafts.setLogicalProjectDraftThreadId( + "github.com/example/shared-repo", + scopeProjectRef(primaryId, primaryProject!.id), + draftId, + ); + // "Add to another machine" hands the open draft to the new checkout this way. + drafts.setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(remoteId, remoteProject!.id), + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + drafts.setPrompt(draftId, "keep this prompt"); + await using screen = await mountPanel(); + + await userEvent.click(removeCheckout(screen, "/work/remote")); + await vi.waitFor(() => expect(state.releaseUploads).toHaveBeenCalledOnce()); + + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toMatchObject({ + environmentId: primaryId, + projectId: primaryProject!.id, + }); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt).toBe( + "keep this prompt", + ); + }); + it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => { setup(); const confirmed = deferred(); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index c1e15267234b..3f370ce7108d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -362,6 +362,13 @@ function ProjectDetail({ if (checkProjectAccess(members, "Failed to remove project")) return; const draftStore = useComposerDraftStore.getState(); + const remainingMember = allMembers.find( + (candidate) => + !members.some( + (member) => + member.environmentId === candidate.environmentId && member.id === candidate.id, + ), + ); for (const member of members) { if (checkProjectAccess([member], "Failed to remove project")) return; const memberThreads = projectThreads.filter( @@ -388,7 +395,13 @@ function ProjectDetail({ memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), ); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); - if (projectDraftThread) { + if (projectDraftThread && remainingMember) { + draftStore.setDraftThreadContext(projectDraftThread.draftId, { + projectRef: scopeProjectRef(remainingMember.environmentId, remainingMember.id), + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + } else if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(projectRef); diff --git a/apps/web/src/components/settings/ProviderAuthenticationSection.tsx b/apps/web/src/components/settings/ProviderAuthenticationSection.tsx index f5c4ee146db2..cff46ee769b1 100644 --- a/apps/web/src/components/settings/ProviderAuthenticationSection.tsx +++ b/apps/web/src/components/settings/ProviderAuthenticationSection.tsx @@ -14,6 +14,7 @@ import { lazy, Suspense, useRef, useState } from "react"; import { CopyIcon } from "lucide-react"; import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { isWebUrl } from "../../browser/webUrl"; import { ensureLocalApi } from "../../localApi"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; @@ -141,6 +142,10 @@ export function ProviderAuthenticationSection({ async function openBrowser() { if (!url || readOnly) return; + if (!isWebUrl(url)) { + setError("This sign-in link is not a web address, so it was not opened."); + return; + } const requiresConsent = interaction?.type === "browser" && interaction.requiresConsent; // Reserve tabs before awaiting consent; browsers block later opens. const pending = requiresConsent && !window.desktopBridge ? window.open("", "_blank") : null; diff --git a/apps/web/src/components/settings/RevokeConnectDeviceDialog.tsx b/apps/web/src/components/settings/RevokeConnectDeviceDialog.tsx new file mode 100644 index 000000000000..ed0a7ee294b7 --- /dev/null +++ b/apps/web/src/components/settings/RevokeConnectDeviceDialog.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Spinner } from "../ui/spinner"; + +export interface ConnectDeviceRevokeRequest { + readonly title: string; + /** Revokes the device; resolves once the attempt finished, failed or not. */ + readonly revoke: () => Promise; +} + +/** + * With approval off, a revoked T3 Connect device signs straight back in, so revoking offers to + * turn approval on first. + */ +export function RevokeConnectDeviceDialog({ + request, + onClose, + requireApproval, +}: { + readonly request: ConnectDeviceRevokeRequest | null; + readonly onClose: () => void; + /** Saves the setting; resolves false when the save failed. */ + readonly requireApproval: () => Promise; +}) { + return ( + { + if (!open) onClose(); + }} + > + {request ? ( + + ) : null} + + ); +} + +function RevokeConnectDeviceDialogPopup({ + request, + onClose, + requireApproval, +}: { + readonly request: ConnectDeviceRevokeRequest; + readonly onClose: () => void; + readonly requireApproval: () => Promise; +}) { + const [turnOnApproval, setTurnOnApproval] = useState(true); + const [busy, setBusy] = useState(false); + + const confirm = async () => { + setBusy(true); + if (turnOnApproval && !(await requireApproval())) { + setBusy(false); + return; + } + await request.revoke(); + setBusy(false); + onClose(); + }; + + return ( + + + {request.title} + + Approval for new devices is off, so a revoked device signed in to your T3 Connect account + can connect again on its own. + + + + + }> + Cancel + + + + + ); +} diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 866841b4bc6b..805d914f1a74 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -60,6 +60,7 @@ import { type EnvironmentPresentation, useRelayEnvironmentDiscovery } from "~/st import { APP_VERSION } from "~/branding"; import { useManagedPolicy } from "~/state/managedPolicy"; import { useAtomCommand } from "../../state/use-atom-command"; +import { useUpdateEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; import { OutdatedServerUpdateAction, @@ -90,6 +91,9 @@ function savedBackendStatus(environment: EnvironmentPresentation): { case "connecting": return { text: "Connecting", tone: "muted" }; case "reconnecting": + if (connection.awaitingApproval) { + return { text: connection.error ?? "Waiting for approval", tone: "muted" }; + } return { text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", tone: "error", @@ -522,10 +526,13 @@ function T3ConnectTunnelUsage({ tunnelLimit }: { readonly tunnelLimit: number | function ConfiguredCloudLinkRow({ environmentId, canManageRelay, + connectDeviceApproval, }: { readonly environmentId: EnvironmentId; readonly canManageRelay: boolean; + readonly connectDeviceApproval: ConnectDeviceApprovalControl | null; }) { + const updateEnvironmentSettings = useUpdateEnvironmentSettings(environmentId); const { isSignedIn, linkState: cloudLinkState, @@ -743,18 +750,48 @@ function ConfiguredCloudLinkRow({ } /> ) : null} + {managedTunnelActive && connectDeviceApproval ? ( + + updateEnvironmentSettings({ requireConnectDeviceApproval: enabled }) + } + /> + } + /> + ) : null} ); } +/** Older servers have no device approval, so callers pass null and the row stays hidden. */ +export interface ConnectDeviceApprovalControl { + readonly required: boolean; + readonly canManage: boolean; +} + export function CloudLinkRow({ environmentId, canReadRelay, canManageRelay, + connectDeviceApproval, }: { readonly environmentId: EnvironmentId; readonly canReadRelay: boolean; readonly canManageRelay: boolean; + readonly connectDeviceApproval: ConnectDeviceApprovalControl | null; }) { if (!hasCloudPublicConfig()) return null; if (!canReadRelay) { @@ -765,7 +802,13 @@ export function CloudLinkRow({ /> ); } - return ; + return ( + + ); } function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 52d572b1479e..0c6695870f9a 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -2,9 +2,16 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { + autoSettleDaysField, + type AutoSettleDaysField, backgroundActivitySharedPolicySettings, + cancelAutoSettleDaysField, + commitAutoSettleDaysField, + editAutoSettleDaysField, + failAutoSettleDaysField, getChangedBrowserSettingLabels, hasChangedBackgroundActivitySettings, + syncAutoSettleDaysField, } from "./SettingsPanels.logic"; describe("background activity settings restore", () => { @@ -88,3 +95,80 @@ describe("getChangedBrowserSettingLabels", () => { ).toEqual(["Search engine"]); }); }); + +describe("auto-settle days field", () => { + const type = (field: AutoSettleDaysField, ...drafts: string[]) => + drafts.reduce(editAutoSettleDaysField, field); + + it("saves only the finished edit, never a value typed on the way", () => { + const typed = type(autoSettleDaysField(14), "1", "", "3", "30"); + expect(typed.pending).toBeNull(); + + const committed = commitAutoSettleDaysField(typed); + expect(committed.submit).toBe(30); + expect(commitAutoSettleDaysField(committed.field).submit).toBeNull(); + + const confirmed = syncAutoSettleDaysField(committed.field, 30); + expect(confirmed).toMatchObject({ saved: 30, draft: "30", pending: null }); + }); + + it.each(["", "0", "91", "1.5", "abc"])("restores the saved value for %j", (draft) => { + const committed = commitAutoSettleDaysField(type(autoSettleDaysField(14), draft)); + expect(committed.submit).toBeNull(); + expect(committed.field.draft).toBe("14"); + }); + + it("a rejected save falls back to the saved value and can be retried", () => { + const submitted = commitAutoSettleDaysField(type(autoSettleDaysField(14), "30")).field; + expect(failAutoSettleDaysField(submitted, submitted.revision).draft).toBe("14"); + + const rejectedWhileCleared = failAutoSettleDaysField(type(submitted, ""), submitted.revision); + expect(rejectedWhileCleared.draft).toBe(""); + expect(commitAutoSettleDaysField(rejectedWhileCleared).field.draft).toBe("14"); + expect(commitAutoSettleDaysField(type(rejectedWhileCleared, "30")).submit).toBe(30); + }); + + it("retyping a value that saved on only some environments sends it again", () => { + const submitted = commitAutoSettleDaysField(type(autoSettleDaysField(14), "30")).field; + const partlySaved = failAutoSettleDaysField( + syncAutoSettleDaysField(submitted, 30), + submitted.revision, + ); + expect(commitAutoSettleDaysField(partlySaved).submit).toBeNull(); + expect(commitAutoSettleDaysField(type(partlySaved, "3", "30")).submit).toBe(30); + }); + + it("returning to the saved value while another save is in flight saves it again", () => { + const submitted = commitAutoSettleDaysField(type(autoSettleDaysField(14), "30")).field; + const back = commitAutoSettleDaysField(type(submitted, "14")); + expect(back.submit).toBe(14); + expect(failAutoSettleDaysField(back.field, submitted.revision)).toBe(back.field); + + const settled = syncAutoSettleDaysField(back.field, 14); + expect(settled.pending).toBeNull(); + expect(syncAutoSettleDaysField(settled, 14)).toBe(settled); + expect(syncAutoSettleDaysField(settled, 20).draft).toBe("20"); + }); + + it("keeps a later save in flight when an earlier one is confirmed", () => { + const first = commitAutoSettleDaysField(type(autoSettleDaysField(14), "30")).field; + const second = commitAutoSettleDaysField(type(first, "60")).field; + const firstConfirmed = syncAutoSettleDaysField(second, 30); + expect(firstConfirmed).toMatchObject({ saved: 30, draft: "60", pending: 60 }); + expect(failAutoSettleDaysField(firstConfirmed, second.revision)).toMatchObject({ + draft: "30", + pending: null, + }); + }); + + it("a cancelled edit restores the saved value and saves nothing", () => { + const cancelled = cancelAutoSettleDaysField(type(autoSettleDaysField(14), "1")); + expect(cancelled).toMatchObject({ draft: "14", dirty: false }); + expect(commitAutoSettleDaysField(cancelled).submit).toBeNull(); + }); + + it("keeps a draft in progress when the saved value changes underneath it", () => { + const typing = type(autoSettleDaysField(14), "4"); + expect(syncAutoSettleDaysField(typing, 30)).toMatchObject({ saved: 30, draft: "4" }); + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 3901226e8b2d..1579cad9a8af 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -8,7 +8,11 @@ import type { SidebarProjectGroupingMode, UnifiedSettings, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + DEFAULT_UNIFIED_SETTINGS, + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, +} from "@t3tools/contracts/settings"; import { getBackgroundActivityBaseProfile, normalizeBackgroundActivitySettings, @@ -271,3 +275,73 @@ export function backgroundActivityOverrideSettings( }, }; } + +/** + * Saving a shorter age settles threads at once, so typing stays in `draft` until the edit is + * finished. `pending` is the submission in flight; `saved` is the only value a draft falls back to. + */ +export interface AutoSettleDaysField { + readonly saved: number; + readonly draft: string; + readonly dirty: boolean; + readonly pending: number | null; + readonly revision: number; +} + +export function autoSettleDaysField(saved: number): AutoSettleDaysField { + return { saved, draft: String(saved), dirty: false, pending: null, revision: 0 }; +} + +export function syncAutoSettleDaysField( + field: AutoSettleDaysField, + saved: number, +): AutoSettleDaysField { + // An earlier save can be confirmed while a later one is still in flight. + const pending = field.pending === saved ? null : field.pending; + if (field.saved === saved && pending === field.pending) return field; + return { + ...field, + saved, + pending, + draft: field.dirty ? field.draft : String(pending ?? saved), + }; +} + +export function editAutoSettleDaysField( + field: AutoSettleDaysField, + draft: string, +): AutoSettleDaysField { + return { ...field, draft, dirty: true }; +} + +export function cancelAutoSettleDaysField(field: AutoSettleDaysField): AutoSettleDaysField { + return { ...field, draft: String(field.pending ?? field.saved), dirty: false }; +} + +export function commitAutoSettleDaysField(field: AutoSettleDaysField): { + readonly field: AutoSettleDaysField; + readonly submit: number | null; +} { + const current = field.pending ?? field.saved; + const days = field.draft.trim() === "" ? Number.NaN : Number(field.draft); + if ( + !Number.isInteger(days) || + days < MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS || + days > MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + ) { + return { field: { ...field, draft: String(current), dirty: false }, submit: null }; + } + const settled = { ...field, draft: String(days), dirty: false }; + // An edit ending on the saved value is sent again: a save can land on the shown environment + // and fail on another, and retyping the number is how it is retried. + if (!field.dirty || days === field.pending) return { field: settled, submit: null }; + return { field: { ...settled, pending: days, revision: field.revision + 1 }, submit: days }; +} + +export function failAutoSettleDaysField( + field: AutoSettleDaysField, + revision: number, +): AutoSettleDaysField { + if (field.revision !== revision) return field; + return { ...field, pending: null, draft: field.dirty ? field.draft : String(field.saved) }; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 31234fb6be51..ea874c2b18c3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -89,6 +89,11 @@ import { ScopedSwitch } from "./ScopedSwitch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { + autoSettleDaysField, + cancelAutoSettleDaysField, + commitAutoSettleDaysField, + editAutoSettleDaysField, + failAutoSettleDaysField, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, hasChangedBackgroundActivitySettings, @@ -96,6 +101,7 @@ import { projectGroupingModeFromToggle, readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, + syncAutoSettleDaysField, } from "./SettingsPanels.logic"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, @@ -732,14 +738,21 @@ function AutoSettleDaysInput({ onCommit, }: { value: number; - onCommit: (days: number) => void; + onCommit: (days: number, options: { onFailed: () => void }) => void; }) { - const [draft, setDraft] = useState(String(value)); - const [draftSource, setDraftSource] = useState(value); - if (draftSource !== value) { - setDraftSource(value); - setDraft(String(value)); - } + const [stored, setField] = useState(() => autoSettleDaysField(value)); + const field = syncAutoSettleDaysField(stored, value); + if (field !== stored) setField(field); + + const commit = () => { + const next = commitAutoSettleDaysField(field); + setField(next.field); + if (next.submit === null) return; + const { revision } = next.field; + onCommit(next.submit, { + onFailed: () => setField((current) => failAutoSettleDaysField(current, revision)), + }); + }; return ( { - setDraft(event.target.value); - const parsed = Number(event.target.value); - if ( - Number.isInteger(parsed) && - parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && - parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS - ) { - onCommit(parsed); + value={field.draft} + onChange={(event) => setField(editAutoSettleDaysField(field, event.target.value))} + onBlur={commit} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") commit(); + if (event.key === "Escape" && field.dirty) { + // An unhandled Escape closes the settings page, and the blur that follows would save. + event.preventDefault(); + event.stopPropagation(); + setField(cancelAutoSettleDaysField(field)); } }} - onBlur={() => setDraft(String(value))} aria-label="Days of inactivity before auto-settle" /> ); @@ -1941,7 +1954,9 @@ export function ThreadsSettingsPanel() { control={ updateSettings({ sidebarAutoSettleAfterDays: days })} + onCommit={(days, options) => + updateSettings({ sidebarAutoSettleAfterDays: days }, options) + } /> } /> diff --git a/apps/web/src/components/settings/ThemeCssEditor.tsx b/apps/web/src/components/settings/ThemeCssEditor.tsx new file mode 100644 index 000000000000..b3ce81e3cd2e --- /dev/null +++ b/apps/web/src/components/settings/ThemeCssEditor.tsx @@ -0,0 +1,124 @@ +import { useId, useState, type KeyboardEvent } from "react"; + +import { Button } from "../ui/button"; + +export function ThemeCssEditor({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const hintId = useId(); + const [position, setPosition] = useState({ line: 1, column: 1 }); + const [tabIndents, setTabIndents] = useState(true); + + function updatePosition(editor: HTMLTextAreaElement) { + const preceding = editor.value.slice(0, editor.selectionStart).split("\n"); + setPosition({ line: preceding.length, column: (preceding.at(-1)?.length ?? 0) + 1 }); + } + + function replaceSelection(editor: HTMLTextAreaElement, replacement: string) { + // Native insertion preserves undo history for indentation as well as typing. + if (!document.execCommand("insertText", false, replacement)) { + editor.setRangeText(replacement, editor.selectionStart, editor.selectionEnd, "end"); + } + onChange(editor.value); + updatePosition(editor); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing || event.ctrlKey || event.metaKey || event.altKey) return; + const editor = event.currentTarget; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setTabIndents(false); + return; + } + if (event.key === "Tab" && tabIndents) { + event.preventDefault(); + const start = editor.selectionStart; + const end = editor.selectionEnd; + const lineStart = start === 0 ? 0 : editor.value.lastIndexOf("\n", start - 1) + 1; + if (start === end && !event.shiftKey) { + replaceSelection(editor, " "); + } else if (start === end) { + const indent = editor.value.slice(lineStart).match(/^(?: {1,2}|\t)/)?.[0] ?? ""; + if (!indent) return; + editor.setSelectionRange(lineStart, lineStart + indent.length); + replaceSelection(editor, ""); + editor.setSelectionRange( + Math.max(lineStart, start - indent.length), + Math.max(lineStart, end - indent.length), + ); + } else { + // A selection ending at a line's start leaves that next line untouched. + const blockEnd = end > start && editor.value[end - 1] === "\n" ? end - 1 : end; + const selected = editor.value.slice(lineStart, blockEnd); + const replacement = selected + .split("\n") + .map((line) => (event.shiftKey ? line.replace(/^(?: {1,2}|\t)/, "") : ` ${line}`)) + .join("\n"); + editor.setSelectionRange(lineStart, blockEnd); + replaceSelection(editor, replacement); + editor.setSelectionRange(lineStart, lineStart + replacement.length); + } + updatePosition(editor); + } else if (event.key === "Enter" && editor.selectionStart === editor.selectionEnd) { + const precedingLine = editor.value.slice(0, editor.selectionStart).split("\n").at(-1) ?? ""; + const indent = precedingLine.match(/^[\t ]*/)?.[0] ?? ""; + event.preventDefault(); + replaceSelection(editor, `\n${indent}${precedingLine.trimEnd().endsWith("{") ? " " : ""}`); + } + } + + return ( +
+
+