diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 2deae61f01b1d..4f7632d6e4850 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -306,6 +306,14 @@ jobs: native/hyprland-snap-shot/target/${{ inputs.rust_target }}/release/t3-hyprland-snap-shot key: linux-capture-helpers-${{ inputs.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} + - name: Ensure Rosetta for macOS x64 bytecode generation + if: inputs.platform == 'mac' && inputs.arch == 'x64' + shell: bash + run: | + if [[ "$(uname -m)" == "arm64" ]] && ! /usr/bin/arch -x86_64 /usr/bin/true; then + sudo /usr/sbin/softwareupdate --install-rosetta --agree-to-license + fi + - name: Setup Rust uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a25e0b413fb66..6d15509030dd2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -400,8 +400,12 @@ jobs: (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 + # Previews build any branch, but production admits only main and v* tags. + # The empty name runs previews with no environment, so their Clerk and relay + # ids come from .env.example. Everything else matches nightly: the tracing + # read below needs only repository-level Cloudflare credentials. environment: - name: production + name: ${{ inputs.channel != 'preview' && 'production' || '' }} outputs: clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} @@ -467,9 +471,19 @@ jobs: - id: public_config name: Resolve production relay public config shell: bash + env: + PREVIEW: ${{ inputs.channel == 'preview' }} run: | set -euo pipefail + if [[ "$PREVIEW" == "true" ]]; then + env_example() { sed -n "s|^T3CODE_$1=||p" .env.example; } + CLERK_PUBLISHABLE_KEY="$(env_example CLERK_PUBLISHABLE_KEY)" + CLERK_JWT_TEMPLATE="$(env_example CLERK_JWT_TEMPLATE)" + CLERK_CLI_OAUTH_CLIENT_ID="$(env_example CLERK_CLI_OAUTH_CLIENT_ID)" + RELAY_DOMAIN="$(sed -n 's|^T3CODE_RELAY_URL=https://||p' .env.example)" + fi + relay_domain="${RELAY_DOMAIN:-}" if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then relay_domain="relay.$RELAY_API_ZONE_NAME" diff --git a/AGENTS.md b/AGENTS.md index d99f1b5fa96a7..be566b9822055 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, and Antigravity each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and Kiro each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Agents.** A capability a user can trigger is usually one an agent should reach through MCP tools, and scheduled tasks run the same paths. That only works when it is a service method, not handler code. - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts new file mode 100644 index 0000000000000..66fc83adae194 --- /dev/null +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -0,0 +1,233 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as HttpClient from "effect/http/HttpClient"; +import * as NetService from "@t3tools/shared/Net"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { vi } from "vite-plus/test"; + +vi.mock("electron", () => ({})); +vi.mock("@clerk/electron", () => ({ createClerkBridge: vi.fn() })); +vi.mock("@clerk/electron/storage", () => ({ storage: vi.fn() })); +vi.mock("../ipc/DesktopIpcHandlers.ts", () => ({ + installDesktopIpcHandlers: () => Effect.void, +})); +vi.mock("../updates/DesktopRemoteUpdates.ts", () => ({ listen: Effect.void })); +vi.mock("./DesktopStartupRecovery.ts", () => ({ listen: Effect.void })); + +import * as DesktopApp from "./DesktopApp.ts"; +import * as DesktopAppActivation from "./DesktopAppActivation.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as PreviewPasskeys from "../preview/Passkeys.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; +import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; +import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; + +import * as BrowserImport from "../preview/BrowserImport/BrowserImport.ts"; +import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; +import * as DesktopCliCommand from "./DesktopCliCommand.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import * as DesktopIpc from "../ipc/DesktopIpc.ts"; +import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; +import * as DesktopManagedPolicy from "./DesktopManagedPolicy.ts"; +import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "../ssh/DesktopSshPasswordPrompts.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopWebLinks from "./DesktopWebLinks.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as MacPermissions from "../permissions/MacPermissions.ts"; +import * as PreviewManager from "../preview/Manager.ts"; + +it.effect( + "starts the backend and window while Linux registration is stalled and cancels it on shutdown", + () => + Effect.gen(function* () { + const registrationStarted = yield* Deferred.make(); + const releaseRegistration = yield* Deferred.make(); + const registrationStopped = yield* Deferred.make(); + const windowStarted = yield* Deferred.make(); + const quitting = yield* Ref.make(false); + const events: string[] = []; + const record = (event: string) => + Effect.sync(() => { + events.push(event); + }); + const runApp = DesktopApp.program + .pipe( + Effect.provide(NodeCrypto.layer), + Effect.provideService(DesktopAppIdentity.DesktopAppIdentity, { + resolveUserDataPath: Effect.succeed("/isolated/userdata"), + configure: record("identity"), + } as DesktopAppIdentity.DesktopAppIdentity["Service"]), + Effect.provideService(ElectronApp.ElectronApp, { + setPath: () => Effect.void, + whenReady: record("ready"), + } as unknown as ElectronApp.ElectronApp["Service"]), + Effect.provideService(ElectronDialog.ElectronDialog, { + showErrorBox: () => Effect.die("unexpected fatal startup error"), + } as unknown as ElectronDialog.ElectronDialog["Service"]), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, { + platform: "linux", + isDevelopment: false, + isPackaged: true, + configuredBackendPort: Option.some(3773), + defaultDesktopSettings: { serverExposureMode: "local-only" }, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]), + Effect.provideService(DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, { + linux: null, + linuxPasswordStoreCommandLine: "basic", + }), + Effect.provideService(DesktopShellEnvironment.DesktopShellEnvironment, { + installIntoProcess: Effect.succeed(true), + awaitDiscovery: Effect.succeed(true), + updates: Stream.empty, + }), + Effect.provideService(DesktopAppSettings.DesktopAppSettings, { + load: Effect.void, + get: Effect.succeed({ + localEnvironmentEnabled: true, + serverExposureMode: "local-only", + }), + } as unknown as DesktopAppSettings.DesktopAppSettings["Service"]), + Effect.provideService(DesktopLifecycle.DesktopLifecycle, { + register: Effect.void, + relaunch: () => Effect.void, + }), + Effect.provideService(DesktopClerk.DesktopClerk, { + configure: Effect.void, + } as DesktopClerk.DesktopClerk["Service"]), + Effect.provideService(ElectronSafeStorage.ElectronSafeStorage, { + selectedStorageBackend: Effect.succeedNone, + } as unknown as ElectronSafeStorage.ElectronSafeStorage["Service"]), + Effect.provideService(PreviewPasskeys.PreviewPasskeys, { + configure: Effect.void, + } as PreviewPasskeys.PreviewPasskeys["Service"]), + Effect.provideService(DesktopApplicationMenu.DesktopApplicationMenu, { + configure: Effect.void, + }), + Effect.provideService(DesktopUpdates.DesktopUpdates, { + configure: Effect.void, + } as unknown as DesktopUpdates.DesktopUpdates["Service"]), + Effect.provideService(DesktopLinuxUrlHandler.DesktopLinuxUrlHandler, { + register: Deferred.succeed(registrationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRegistration)), + Effect.ensuring(Deferred.succeed(registrationStopped, undefined)), + ), + }), + ) + .pipe( + Effect.provideService(ElectronProtocol.ElectronProtocol, { + registerDesktopProtocol: () => Effect.void, + } as unknown as ElectronProtocol.ElectronProtocol["Service"]), + Effect.provideService(DesktopLegacyLocalStorage.DesktopLegacyLocalStorage, { + load: () => Effect.void, + } as unknown as DesktopLegacyLocalStorage.DesktopLegacyLocalStorage["Service"]), + Effect.provideService(DesktopSnapShot.DesktopSnapShot, { + initialize: Effect.void, + } as DesktopSnapShot.DesktopSnapShot["Service"]), + Effect.provideService(DesktopState.DesktopState, { + quitting, + } as DesktopState.DesktopState["Service"]), + Effect.provideService(DesktopWindow.DesktopWindow, { + ensureMain: record("window").pipe( + Effect.andThen(Deferred.succeed(windowStarted, undefined)), + ), + } as unknown as DesktopWindow.DesktopWindow["Service"]), + Effect.provideService(DesktopBackendPool.DesktopBackendPool, { + primary: Effect.succeed({ + start: record("backend"), + currentConfig: Effect.succeedSome({ preflightFailure: Option.none() }), + }), + list: Effect.succeed([]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, { + configureFromSettings: () => Effect.succeed({ mode: "local-only" }), + backendConfig: Effect.succeed({ httpBaseUrl: new URL("http://127.0.0.1:3773") }), + } as unknown as DesktopServerExposure.DesktopServerExposure["Service"]), + Effect.provideService(DesktopWslBackend.DesktopWslBackend, { + reconcile: Effect.void, + } as DesktopWslBackend.DesktopWslBackend["Service"]), + Effect.provideService(DesktopAppActivation.DesktopAppActivation, { + start: Effect.void, + } as unknown as DesktopAppActivation.DesktopAppActivation["Service"]), + Effect.provideService(DesktopRendererHistory.DesktopRendererHistory, { + shutdown: Effect.void, + } as DesktopRendererHistory.DesktopRendererHistory["Service"]), + ); + const app = yield* runApp.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(BrowserImport.BrowserImport, {}), + Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration, {}), + Layer.mock(DesktopCliCommand.DesktopCliCommand, {}), + Layer.mock(DesktopClientSettings.DesktopClientSettings, {}), + Layer.mock(DesktopConnectionCatalogStore.DesktopConnectionCatalogStore, {}), + Layer.mock(DesktopIpc.DesktopIpc, {}), + Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, {}), + Layer.mock(DesktopManagedPolicy.DesktopManagedPolicy, {}), + Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, {}), + Layer.mock(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, {}), + Layer.mock(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {}), + Layer.mock(DesktopWebLinks.DesktopWebLinks, {}), + Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {}), + Layer.mock(ElectronMenu.ElectronMenu, {}), + Layer.mock(ElectronShell.ElectronShell, {}), + Layer.mock(ElectronTheme.ElectronTheme, {}), + Layer.mock(ElectronWindow.ElectronWindow, {}), + Layer.mock(MacPermissions.MacPermissions, {}), + Layer.mock(PreviewManager.PreviewManager, { + isBrowserPartition: () => { + throw new Error("unexpected browser partition lookup"); + }, + }), + FileSystem.layerNoop({}), + Layer.mock(NetService.NetService, {}), + Path.layer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HTTP request")), + ), + ), + ), + Effect.forkScoped, + ); + yield* Deferred.await(registrationStarted); + yield* Deferred.await(windowStarted); + assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]); + assert.isFalse(yield* Deferred.isDone(registrationStopped)); + yield* (yield* DesktopShutdown.DesktopShutdown).request; + yield* Fiber.join(app); + assert.isTrue(yield* Deferred.isDone(registrationStopped)); + }).pipe(Effect.provide(DesktopShutdown.layer)), +); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d49d2cdebb701..8bdd1957cbfb7 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -6,7 +6,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as NetService from "@t3tools/shared/Net"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -42,8 +42,8 @@ const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; -const makeDesktopRunId = Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), +const makeDesktopRunId = SecureRandom.SecureRandom.pipe( + Effect.flatMap((random) => random.uuidV4()), Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), ); @@ -245,6 +245,16 @@ const bootstrap = Effect.gen(function* () { } 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; + } yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), @@ -327,7 +337,8 @@ const startup = Effect.gen(function* () { yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* DesktopStartupRecovery.listen; - yield* linuxUrlHandler.register; + // 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))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 8b4fc23a99335..98c75385afc51 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; @@ -75,6 +76,7 @@ function layerFor( layerEnvironment, layerSafeStorage, NodeServices.layer, + NodeCrypto.layer, fileSystemLayer, ); const layerSavedEnvironments = DesktopSavedEnvironments.layer.pipe( diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index ceb3f293550bf..3240df3636e38 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -13,7 +13,7 @@ import { import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; import * as FileSystem from "effect/FileSystem"; @@ -381,7 +381,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( @@ -410,16 +410,7 @@ export const make = Effect.gen(function* () { ), ), ); - const suffix = (yield* crypto.randomUUIDv4().pipe( - Effect.mapError( - (cause) => - new DesktopConnectionCatalogStoreWriteError({ - operation: "create-temporary-file-name", - path: catalogPath, - cause, - }), - ), - )).replace(/-/g, ""); + const suffix = (yield* random.uuidV4()).replace(/-/g, ""); yield* writeDocument({ fileSystem, path, diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 1d1fff2061df1..bab9eb5de3235 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -381,7 +381,7 @@ describe("DesktopLinuxUrlHandler", () => { if (!ChildProcess.isStandardCommand(command)) { return yield* Effect.die("expected a single registration command"); } - recorded.commands.push({ command: command.command, args: command.args }); + recorded.commands.push({ command: command.command, args: command.args.map(String) }); if (command.command !== stalledHelper) return mockProcess(0); const child = yield* realSpawner.spawn( ChildProcess.make( diff --git a/apps/desktop/src/app/DesktopStartupRecovery.test.ts b/apps/desktop/src/app/DesktopStartupRecovery.test.ts index d8a0ef1a999cc..12d5289af67c7 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.test.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.test.ts @@ -1,8 +1,23 @@ import type { DesktopUpdateState } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import { createInitialDesktopUpdateState } from "../updates/updateMachine.ts"; -import { resolveStartupRecoveryOffer } from "./DesktopStartupRecovery.ts"; +import { listen, resolveStartupRecoveryOffer } from "./DesktopStartupRecovery.ts"; const updateState = (patch: Partial): DesktopUpdateState => ({ ...createInitialDesktopUpdateState( @@ -68,3 +83,117 @@ describe("resolveStartupRecoveryOffer", () => { }); }); }); + +it.effect.each([ + { visible: false, started: false, closeMain: false }, + { visible: true, started: false, closeMain: false }, + { visible: true, started: true, closeMain: false }, + { visible: true, started: false, closeMain: true }, +])("closes recovery safely with closeMain=$closeMain", ({ visible, started, closeMain }) => + Effect.scoped( + Effect.gen(function* () { + const checked = yield* Deferred.make(); + const remembered = yield* Deferred.make(); + const shouldRecover = !visible || !started; + const closed = Promise.withResolvers(); + const loaded: string[] = []; + const appListeners = new Map void>(); + const mainListeners = new Map void>(); + let closeCount = 0; + let mainVisible = visible; + const main = { + isDestroyed: () => false, + isVisible: () => mainVisible, + on: (event: string, listener: () => void) => mainListeners.set(event, listener), + once: (event: string, listener: () => void) => mainListeners.set(event, listener), + } as unknown as Electron.BrowserWindow; + const mainRef = yield* Ref.make(Option.some(main)); + const recovery = { + isDestroyed: () => false, + close: () => { + closeCount++; + closed.resolve(); + }, + loadURL: (url: string) => { + loaded.push(url); + return Promise.resolve(); + }, + once: () => {}, + webContents: { setWindowOpenHandler: () => {}, on: () => {} }, + } as unknown as Electron.BrowserWindow; + const state = yield* DesktopState.DesktopState; + if (started) yield* SubscriptionRef.set(state.primaryBackendStartup, { _tag: "Ready" }); + yield* listen.pipe( + Effect.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: () => Effect.succeed("0.1.2"), + writeFileString: () => Deferred.succeed(remembered, undefined).pipe(Effect.asVoid), + }), + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + appVersion: "0.1.3", + stateDir: "/task/state", + logDir: "/task/logs", + path: { join: (...parts: string[]) => parts.join("/") }, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.succeed(ElectronApp.ElectronApp, { + on: (event: string, listener: (...args: readonly unknown[]) => void) => + Effect.sync(() => { + appListeners.set(event, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]), + Layer.succeed( + ElectronShell.ElectronShell, + {} as ElectronShell.ElectronShell["Service"], + ), + Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + } as ElectronTheme.ElectronTheme["Service"]), + Layer.succeed(ElectronWindow.ElectronWindow, { + main: Ref.get(mainRef), + create: () => Effect.succeed(recovery), + } as unknown as ElectronWindow.ElectronWindow["Service"]), + Layer.succeed(DesktopUpdates.DesktopUpdates, { + subscribe: Effect.succeed({ + latest: updateState({ status: "disabled" }), + changes: Stream.empty, + }), + check: () => Deferred.succeed(checked, undefined), + } as unknown as DesktopUpdates.DesktopUpdates["Service"]), + ), + ), + ); + appListeners.get("browser-window-created")?.({}, main); + if (started) yield* Deferred.await(remembered); + yield* SubscriptionRef.set(state.primaryBackendStartup, { + _tag: "Failing", + reason: "Server failed before readiness", + output: "Failure details", + }); + yield* Deferred.await(checked); + expect(loaded.length).toBe(shouldRecover ? 1 : 0); + if (shouldRecover) { + expect(decodeURIComponent(loaded[0]!)).toContain("Failure details"); + if (closeMain) yield* Ref.set(mainRef, Option.none()); + yield* SubscriptionRef.set(state.primaryBackendStartup, { _tag: "Ready" }); + yield* Deferred.await(remembered); + if (closeMain || !visible) { + expect(closeCount).toBe(0); + if (closeMain) { + yield* Ref.set(mainRef, Option.some(main)); + mainVisible = false; + appListeners.get("browser-window-created")?.({}, main); + } + mainListeners.get("ready-to-show")?.(); + expect(closeCount).toBe(0); + mainVisible = true; + const show = mainListeners.get("show"); + expect(show).toBeDefined(); + show!(); + } + yield* Effect.promise(() => closed.promise); + expect(closeCount).toBe(1); + } + }), + ).pipe(Effect.provide(DesktopState.layer)), +); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts index 5d441923d3d34..c11d8f2d86e90 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -167,6 +167,7 @@ export const listen: Effect.Effect< const failureRef = yield* Ref.make(Option.none()); const renderedHtmlRef = yield* Ref.make(""); const checkedRef = yield* Ref.make(false); + let backendHasStarted = false; const { latest, changes: updateChanges } = yield* updates.subscribe; const updateStateRef = yield* Ref.make(latest); @@ -230,8 +231,15 @@ export const listen: Effect.Effect< const show = Effect.fn("desktop.startupRecovery.show")( function* () { if (yield* Ref.get(quitting)) return; - // A backend that crashes after the app opened is reported by the app itself. - if (Option.isSome(yield* electronWindow.main)) return; + // A visible boot shell still needs recovery if the backend has never started. + const main = yield* electronWindow.main; + if ( + backendHasStarted && + Option.isSome(main) && + !main.value.isDestroyed() && + main.value.isVisible() + ) + return; const existing = yield* Ref.get(windowRef); if (Option.isSome(existing) && !existing.value.isDestroyed()) { yield* electronWindow.reveal(existing.value); @@ -285,6 +293,19 @@ export const listen: Effect.Effect< if (Option.isSome(window) && !window.value.isDestroyed()) window.value.close(); }); + const closeWhenMainReady = Effect.gen(function* () { + if ((yield* SubscriptionRef.get(primaryBackendStartup))._tag !== "Ready") return; + const main = yield* electronWindow.main; + if (Option.isSome(main) && !main.value.isDestroyed() && main.value.isVisible()) yield* close; + }); + + yield* electronApp.on( + "browser-window-created", + (_event: Electron.Event, window: Electron.BrowserWindow) => { + window.on("show", () => runFork(closeWhenMainReady)); + }, + ); + const rememberStartedVersion = Effect.gen(function* () { if (lastStartedVersion === environment.appVersion) return; yield* fileSystem.writeFileString(lastStartedVersionPath, environment.appVersion); @@ -303,8 +324,9 @@ export const listen: Effect.Effect< Stream.runForEach( Effect.fn("desktop.startupRecovery.onStartupChange")(function* (startup) { if (startup._tag === "Ready") { + backendHasStarted = true; yield* Ref.set(failureRef, Option.none()); - yield* close; + yield* closeWhenMainReady; yield* rememberStartedVersion; return; } diff --git a/apps/desktop/src/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts index 9b5bc89148dea..e4259f66fb5e0 100644 --- a/apps/desktop/src/app/DesktopWebLinks.test.ts +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -4,6 +4,7 @@ import * as NodeEvents from "node:events"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; import * as DesktopWebLinks from "./DesktopWebLinks.ts"; @@ -21,6 +22,11 @@ const makeWindow = () => { const reload = () => webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); const layer = DesktopWebLinks.layer.pipe( + Layer.provide( + Layer.succeed(DesktopWindow.DesktopWindow, { + activate: Effect.sync(() => void revealed.push(window)), + } as unknown as DesktopWindow.DesktopWindow["Service"]), + ), Layer.provide( Layer.succeed( ElectronWindow.ElectronWindow, diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index ce63a710f7f7e..c1932c5ce9055 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; @@ -24,6 +25,7 @@ export class DesktopWebLinks extends Context.Service< const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; + const desktopWindow = yield* DesktopWindow.DesktopWindow; const pending: Array = []; let listening: Electron.WebContents | null = null; let detachListening: (() => void) | null = null; @@ -43,7 +45,7 @@ const make = Effect.gen(function* () { return; } for (const url of pending.splice(0)) listening.send(WEB_LINK_OPEN_CHANNEL, url); - yield* electronWindow.reveal(window.value); + yield* desktopWindow.activate.pipe(Effect.ignore); }); const startListening = Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 5e2534a831693..f7a1e147a9e4a 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; @@ -76,6 +77,7 @@ function layerEnvironment( Layer.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, T3CODE_PORT: "9999", @@ -127,7 +129,7 @@ const withHarness = ( ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); interface PackagedWslHarnessContext { readonly baseDir: string; @@ -219,7 +221,7 @@ const withPackagedWslHarness = ( ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); describe("DesktopBackendConfiguration", () => { it.effect.each( @@ -264,7 +266,7 @@ describe("DesktopBackendConfiguration", () => { ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("resolvePrimary produces a stable scoped bootstrap token", () => @@ -361,7 +363,7 @@ describe("DesktopBackendConfiguration", () => { path.join(resourcesPath, "server.asar/apps/server/dist/bin.mjs"), ); assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("resolveWsl pins a default-tracking run to the concrete default distro", () => @@ -421,7 +423,7 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(config.args.slice(0, 2), ["-d", "Ubuntu"]); assert.deepEqual(observedDistros, ["Ubuntu", "Ubuntu", "Ubuntu"]); assert.isTrue(Option.isNone(config.preflightFailure)); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("resolveWsl launches a packaged backend from the WSL-local runtime cache", () => { @@ -710,7 +712,7 @@ describe("DesktopBackendConfiguration", () => { assert.notInclude(config.args, "/bin/sh"); assert.notInclude(config.args, "-c"); assert.isTrue(Option.isNone(config.preflightFailure)); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("resolvePrimary and resolveWsl share one token under concurrent resolution", () => @@ -778,7 +780,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => @@ -845,7 +847,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); for (const [name, value] of ambientOtel) restoreEnv(name, value); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -882,7 +884,7 @@ describe("DesktopBackendConfiguration", () => { ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -925,7 +927,7 @@ describe("DesktopBackendConfiguration", () => { ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("resolveWsl marks a missing packaged server entry as fatal", () => @@ -958,7 +960,7 @@ describe("DesktopBackendConfiguration", () => { ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("prefers the external packaged resource monitor over the copy inside the asar", () => @@ -1007,7 +1009,7 @@ describe("DesktopBackendConfiguration", () => { ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { @@ -1028,7 +1030,7 @@ describe("DesktopBackendConfiguration", () => { ), ), Layer.provideMerge(layerEnvironment("/tmp/t3-wsl-isavailable", { platform: "win32" })), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); try { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 55b51a02425f1..9c085ca91781b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -5,7 +5,7 @@ import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapTo import { PSTACK_SOURCE_ROOT_ENV } from "@t3tools/shared/pstack"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -736,14 +736,14 @@ export const make = Effect.gen(function* () { const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; - const crypto = yield* Crypto.Crypto; + 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 getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { onSome: (secret) => Effect.succeed([secret, current] as const), onNone: () => - crypto.randomBytes(32).pipe( + random.randomBytes(32).pipe( Effect.map((bytes) => { const secret = Hex.encode(bytes); return [secret, Option.some(secret)] as const; diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 220143d6cf45c..a5c12043d9aaa 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -192,6 +192,49 @@ function makeTestInstance(input: MakeInstanceInput) { } describe("DesktopBackendManager", () => { + it.effect("notifies readiness waiters on HTTP readiness and stop without clock advancement", () => + Effect.scoped( + Effect.gen(function* () { + const probe = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exit = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.acquireRelease( + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(exit), + kill: () => + Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), + }), + ), + () => Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)), + ), + ), + ), + httpClientLayer: Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Deferred.succeed(probe, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + Effect.as(HttpClientResponse.fromWeb(request, new Response("{}"))), + ), + ), + ), + }); + yield* instance.start; + yield* Deferred.await(probe); + const waiter = yield* instance.waitForReady(Duration.seconds(15)).pipe(Effect.forkChild); + yield* Deferred.succeed(ready, undefined); + assert.isTrue(yield* Fiber.join(waiter)); + yield* instance.stop(); + assert.isFalse(yield* instance.waitForReady(Duration.seconds(15))); + }), + ), + ); + it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 1930be355513f..aa3b4d59ef1bf 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -8,7 +8,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; @@ -629,7 +629,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const state = yield* Ref.make(initialState); + const state = yield* SubscriptionRef.make(initialState); const mutex = yield* Semaphore.make(1); const outputTail = yield* Ref.make(""); // One streaming decoder per stream keeps UTF-8 characters split across chunks intact. @@ -644,9 +644,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => - Ref.update(state, withActiveRun(runId, f)); + SubscriptionRef.update(state, withActiveRun(runId, f)); - const snapshot = Ref.get(state).pipe( + const snapshot = SubscriptionRef.get(state).pipe( Effect.map((current): DesktopBackendSnapshot => ({ desiredRunning: current.desiredRunning, ready: current.ready, @@ -655,10 +655,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( restartScheduled: Option.isSome(current.restartFiber), })), ); - const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); + const currentConfig = SubscriptionRef.get(state).pipe(Effect.map((current) => current.config)); const cancelRestart = Effect.gen(function* () { - const restartFiber = yield* Ref.modify(state, (current) => [ + const restartFiber = yield* SubscriptionRef.modify(state, (current) => [ current.restartFiber, { ...current, @@ -675,10 +675,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { - const current = yield* Ref.get(state); + const current = yield* SubscriptionRef.get(state); if (Option.isSome(current.active)) { if (!current.desiredRunning) { - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: true, })); @@ -688,7 +688,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (current.ready) { yield* spec.onShutdown?.() ?? Effect.void; - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.ready ? { ...latest, ready: false } : latest, ); } @@ -713,7 +713,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const resetFatalPreflightCounter = !current.desiredRunning && current.preflightFailureAttempt > 0; yield* cancelRestart; - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: true, ready: false, @@ -725,7 +725,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (Option.isSome(preflightFailure)) { const { reason, fatal, retryLimit } = preflightFailure.value; if (!fatal && retryLimit === undefined) { - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, @@ -734,7 +734,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } const attemptLimit = retryLimit ?? MAX_PREFLIGHT_FAILURE_ATTEMPTS; - const attempt = yield* Ref.modify(state, (latest) => { + const attempt = yield* SubscriptionRef.modify(state, (latest) => { const next = latest.preflightFailureAttempt + 1; return [next, { ...latest, preflightFailureAttempt: next }] as const; }); @@ -743,7 +743,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( reason, attempt, }); - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: false, ready: false, @@ -761,7 +761,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (shouldRestart) { yield* scheduleRestart(reason); } else { - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: false, ready: false, @@ -772,7 +772,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* scheduleRestart(reason); return; } - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, ); @@ -782,7 +782,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( } const runScope = yield* Scope.make("sequential"); - const runId = yield* Ref.modify(state, (latest) => [ + const runId = yield* SubscriptionRef.modify(state, (latest) => [ latest.nextRunId, { ...latest, @@ -806,7 +806,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* mutex.withPermits(1)( Effect.gen(function* () { const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } = - yield* Ref.modify( + yield* SubscriptionRef.modify( state, ( latest, @@ -873,7 +873,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (isCurrentRun && nextState.desiredRunning) { if (stateDirOwned && !stopRequested) { - yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: false })); + yield* SubscriptionRef.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + })); yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore); return; } @@ -907,7 +910,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( exitObserved: true, })), onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { - const isCurrentRun = yield* Ref.modify(state, (latest) => { + const isCurrentRun = yield* SubscriptionRef.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { return [false, latest] as const; @@ -978,7 +981,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( reason: string, ) { - const scheduled = yield* Ref.modify(state, (latest) => { + const scheduled = yield* SubscriptionRef.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { return [Option.none<{ delay: Duration.Duration; attempt: number }>(), latest] as const; } @@ -1006,7 +1009,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( - Ref.modify(state, (latest) => { + SubscriptionRef.modify(state, (latest) => { const shouldRestart = latest.desiredRunning; return [ shouldRestart, @@ -1026,7 +1029,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ), parentScope, ); - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => Option.isNone(latest.restartFiber) ? { ...latest, @@ -1043,7 +1046,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }) { const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)( Effect.gen(function* () { - const result = yield* Ref.modify(state, (latest) => { + const result = yield* SubscriptionRef.modify(state, (latest) => { const active = Option.map(latest.active, (run) => run.exitObserved ? run : { ...run, stopRequested: true }, ); @@ -1082,7 +1085,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } const cleanup = yield* mutex.withPermits(1)( - Ref.modify( + SubscriptionRef.modify( state, ( latest, @@ -1130,23 +1133,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); }); - const waitForReady: (timeout: Duration.Duration) => Effect.Effect = Effect.fn( - "DesktopBackendManager.waitForReady", - )( - function* (_timeout: Duration.Duration) { - const current = yield* Ref.get(state); - if (!current.desiredRunning) return { done: true, ready: false }; - return current.ready ? { done: true, ready: true } : { done: false, ready: false }; - }, - (effect, timeout) => - effect.pipe( - Effect.repeat({ - until: (status) => status.done, - schedule: Schedule.spaced(Duration.millis(100)), - }), - Effect.map((status) => status.ready), + const waitForReady = Effect.fn("DesktopBackendManager.waitForReady")( + (timeout: Duration.Duration) => + SubscriptionRef.changes(state).pipe( + Stream.filter((current) => current.ready || !current.desiredRunning), + Stream.map((current) => current.ready), + Stream.runHead, Effect.timeoutOption(timeout), - Effect.map(Option.getOrElse(() => false)), + Effect.map( + (result) => Option.isSome(result) && Option.getOrElse(result.value, () => false), + ), ), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 348d3a7050f70..be16c4ecc43ee 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -40,6 +41,8 @@ describe("DesktopLocalEnvironmentAuth", () => { it.effect("exchanges the desktop bootstrap credential only once", () => Effect.gen(function* () { const requestCount = yield* Ref.make(0); + const waiting = yield* Deferred.make(); + const ready = yield* Deferred.make(); const layerHttpClient = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -68,6 +71,11 @@ describe("DesktopLocalEnvironmentAuth", () => { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), currentConfig: Effect.succeedSome(config), + waitForReady: (timeout: Duration.Duration) => + Deferred.succeed(waiting, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + Effect.timeout(timeout), + ), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); @@ -77,13 +85,20 @@ describe("DesktopLocalEnvironmentAuth", () => { const [first, second] = yield* Effect.gen(function* () { const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; - return yield* Effect.all([auth.getBearerToken, auth.getBearerToken]); + const tokens = yield* Effect.all([auth.getBearerToken, auth.getBearerToken]).pipe( + Effect.forkChild, + ); + yield* Deferred.await(waiting); + yield* TestClock.adjust(Duration.seconds(16)); + assert.strictEqual(yield* Ref.get(requestCount), 0); + yield* Deferred.succeed(ready, true); + return yield* Fiber.join(tokens); }).pipe(Effect.provide(layerTest)); assert.strictEqual(first, "desktop-bearer-token"); assert.strictEqual(second, "desktop-bearer-token"); assert.strictEqual(yield* Ref.get(requestCount), 1); - }), + }).pipe(Effect.provide(TestClock.layer())), ); it.effect( @@ -120,6 +135,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome({ ...config, bootstrap: { ...config.bootstrap, desktopBootstrapSecret: "desktop-secret" }, @@ -167,6 +183,7 @@ describe("DesktopLocalEnvironmentAuth", () => { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), currentConfig: Effect.succeedSome(config), + waitForReady: () => Effect.succeed(true), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 6481bfd64c897..e916b35053e8f 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -80,7 +80,10 @@ export const make = Effect.gen(function* () { const instances = yield* pool.list; const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); - const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; + if (primary === undefined || !(yield* primary.waitForReady(Duration.infinity))) { + return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); + } + const configOption = yield* primary.currentConfig; if (Option.isNone(configOption)) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 23732860e05cd..c222237dffa7b 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -1,5 +1,6 @@ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -81,7 +82,11 @@ function layerEnvironmentFor(baseDir: string, env: Record( }), ), ); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped); describe("DesktopServerExposure", () => { it.effect("uses cached Tailscale identity to exclude another VPN's pairing address", () => { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 1e4f5d103d6e3..52a2d08dafd58 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -41,6 +41,7 @@ import { getSystemLocale, getWindowFullscreenState, openExternal, + readMedia, openSystemSettings, checkSystemPermission, pasteAsText, @@ -143,6 +144,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(readMedia); yield* ipc.handle(receiveProviderAuthCallback); yield* ipc.handle(cancelProviderAuthCallback); yield* ipc.handle(openSystemSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 1012338043448..33544578cbbd7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -5,6 +5,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const READ_MEDIA_CHANNEL = "desktop:read-media"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index eacc92c94996d..2ddc672891755 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { vi } from "vite-plus/test"; +import type * as Electron from "electron"; const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ focusedWebContents: vi.fn(), @@ -33,6 +34,7 @@ import { installRelayTelemetryEnabled, pickProjectFavicon, probeRemoteEditors, + readMedia, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -234,6 +236,54 @@ describe("installRelayTelemetryEnabled", () => { ); }); +describe("readMedia", () => { + it.effect("reads http(s) media only for the main window", () => + Effect.gen(function* () { + const mib = new Uint8Array(1024 * 1024); + const fetch = vi.fn(async (url: string) => { + if (url.endsWith("/missing.png")) return new Response("Not Found", { status: 404 }); + if (url.endsWith("/small.png")) return new Response(new Uint8Array([1, 2])); + let sent = 0; + // 257 MiB from one reused chunk: over the cap without allocating it. + const huge = new ReadableStream({ + pull: (controller) => (sent++ < 257 ? controller.enqueue(mib) : controller.close()), + }); + return new Response(huge); + }); + const main = { + webContents: { id: 1, session: { fetch } }, + } as unknown as Electron.BrowserWindow; + const read = (url: string, senderId: number) => + readMedia + .handler(url, { sender: { id: senderId } }) + .pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ main: Effect.succeedSome(main) }), + ), + ); + + assert.strictEqual(yield* read("https://example.com/a.png", 2), null); + assert.strictEqual(yield* read("file:///etc/passwd", 1), null); + assert.strictEqual(fetch.mock.calls.length, 0); + assert.deepEqual(yield* read("https://example.com/small.png", 1), { + status: 200, + mimeType: "", + data: Buffer.from([1, 2]), + }); + assert.deepEqual(yield* read("https://example.com/missing.png", 1), { + status: 404, + mimeType: "text/plain;charset=UTF-8", + data: new Uint8Array(), + }); + assert.deepEqual(yield* read("https://example.com/huge.mp4", 1), { + status: 413, + mimeType: "", + data: new Uint8Array(), + }); + }), + ); +}); + describe("pickProjectFavicon", () => { const pickerLayer = ( pickFiles: () => Effect.Effect>, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 473d1874078be..63b8db03dad03 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -2,6 +2,7 @@ import { ContextMenuItemSchema, DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, + DesktopMediaReadSchema, DesktopThemeSchema, EDITORS, EditorId, @@ -311,6 +312,47 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +const READ_MEDIA_MAX_BYTES = 256 * 1024 * 1024; + +// The renderer can display media it cannot fetch(); reading here keeps Save and Copy working for it. +export const readMedia = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.READ_MEDIA_CHANNEL, + payload: Schema.String, + result: Schema.NullOr(DesktopMediaReadSchema), + handler: Effect.fn("desktop.ipc.window.readMedia")(function* (url, event) { + const window = yield* (yield* ElectronWindow.ElectronWindow).main; + if ( + event === undefined || + Option.isNone(window) || + window.value.webContents.id !== event.sender.id || + !/^https?:$/.test(URL.parse(url)?.protocol ?? "") + ) { + return null; + } + const session = window.value.webContents.session; + return yield* Effect.tryPromise(async () => { + // Like an , send the session's cookies. + const response = await session.fetch(url, { credentials: "include" }); + const mimeType = response.headers.get("content-type") ?? ""; + if (!response.ok || !response.body) { + await response.body?.cancel(); + return { status: response.status, mimeType, data: new Uint8Array() }; + } + // The whole body crosses IPC, so stop before a huge file can exhaust main-process memory. + const chunks: Uint8Array[] = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.byteLength; + if (size > READ_MEDIA_MAX_BYTES) { + return { status: 413, mimeType, data: new Uint8Array() }; + } + chunks.push(chunk); + } + return { status: response.status, mimeType, data: Buffer.concat(chunks) }; + }).pipe(Effect.orElseSucceed(() => null)); + }), +}); + export const openSystemSettings = DesktopIpc.makeIpcMethod({ channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, payload: SystemSettingsPaneSchema, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2fcb8d968c5a4..d109518be15e4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,6 +7,7 @@ for (const stream of [process.stdout, process.stderr]) { import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; 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"; @@ -230,12 +231,12 @@ const layerDesktopClerk = DesktopClerk.layer.pipe( Layer.provide(DesktopPreReadyFileSystem.layer), Layer.provideMerge(ElectronShell.layer), Layer.provideMerge(layerDesktopEnvironment), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(ElectronApp.layer), ); const layerDesktopApplicationRuntime = layerDesktopApplication.pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(NetService.layer), Layer.provideMerge(layerElectron), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 8f7987107dfe3..1763968abde7f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -203,6 +203,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { cancelProviderAuthCallback: (url: string) => ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + readMedia: (url: string) => ipcRenderer.invoke(IpcChannels.READ_MEDIA_CHANNEL, url), checkSystemPermission: (pane: string) => ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane), openSystemSettings: (pane: string) => diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 6ee471c211324..7a339102f19a6 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -46,7 +47,7 @@ const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, { showErrorBox: () => Effect.void, }); const layer = BrowserSession.layer.pipe( - Layer.provide(Layer.merge(NodeServices.layer, layerDialog)), + Layer.provide(Layer.merge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), layerDialog)), ); describe("BrowserSession", () => { diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index a427708a37080..a780cacf11ea9 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -1,7 +1,7 @@ import type { Session, WebContents } from "electron"; import { BrowserWindow, session } from "electron"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Layer from "effect/Layer"; @@ -186,7 +186,7 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined /** @public */ export const make = Effect.gen(function* BrowserSessionMake() { - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. @@ -261,7 +261,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { persistent = true, namespace?: BrowserSessionPartitionNamespace, ) { - const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe( + const digest = yield* crypto.digest(Digest.sha256, encodeScopeForDigest(scope)).pipe( Effect.mapError( (cause) => new BrowserSessionPartitionDerivationError({ diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index 579b564f59aa1..a0889940a6682 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -124,6 +124,59 @@ describe("preview guest input", () => { }, ); + it.each([{ userClicksStuckTab: false }, { userClicksStuckTab: true }])( + "keeps the right focus when a sibling press finishes beside a stuck one, user clicks: $userClicksStuckTab", + async ({ userClicksStuckTab }) => { + const document = { activeElement: {} as object }; + const composer = { + isConnected: true, + focus: () => { + document.activeElement = composer; + }, + }; + document.activeElement = composer; + const context = NodeVM.createContext({ document }); + const host = { + isDestroyed: () => false, + executeJavaScript: async (script: string) => NodeVM.runInContext(script, context), + }; + const previews = new Map( + [7, 8].map((id) => [id, { tagName: "WEBVIEW", getWebContentsId: () => id }]), + ); + const stuckPressFocused = Promise.withResolvers(); + const stuckPressEnds = Promise.withResolvers(); + const press = (id: number) => { + const debuggee = { + sendCommand: async () => { + document.activeElement = previews.get(id)!; + if (id === 7) { + stuckPressFocused.resolve(); + await stuckPressEnds.promise; + } else if (userClicksStuckTab) { + document.activeElement = previews.get(7)!; + } + return {}; + }, + }; + return sendPageCommand( + { id, focusedFrame: null, hostWebContents: host } as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + "Input.dispatchMouseEvent", + { type: "mousePressed", x: 10, y: 10, button: "left" }, + ); + }; + // A press held open, as by a page's confirm() on mousedown, must not block or win focus. + const stuck = press(7); + await stuckPressFocused.promise; + await press(8); + const expected = userClicksStuckTab ? previews.get(7) : composer; + expect(document.activeElement).toBe(expected); + stuckPressEnds.resolve(); + await stuck; + expect(Object.keys(context)).toEqual(["document"]); + }, + ); + it.each([ { method: "Input.dispatchMouseEvent", diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index d3ccf29c76ed7..2af13af8d7daa 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -92,7 +92,8 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => { return reply.result?.value; }; -let focusSequence = 0; +/** Host focus shared by the presses in flight in one window, so a later press keeps the field an earlier one saved. */ +const HOST_FOCUS = "globalThis.__t3PreviewFocus"; /** A CDP pointer press focuses the guest's native view even with focus emulation enabled. */ const sendPointerPress = async ( @@ -106,22 +107,36 @@ const sendPointerPress = async ( if (!host || host.isDestroyed() || webContents.focusedFrame) { return debuggee.sendCommand(method, params, sessionId); } - const key = JSON.stringify(`__t3PreviewFocus${++focusSequence}`); + // Focus in a webview with a press in flight came from that press, so keep the earlier save. await host.executeJavaScript(`(() => { + const state = (${HOST_FOCUS} ??= { saved: null, presses: [] }); const active = document.activeElement; - if (!active || active === document.body || active.tagName === "WEBVIEW") return; - globalThis[${key}] = () => { - delete globalThis[${key}]; - const current = document.activeElement; - if (active.isConnected && current?.tagName === "WEBVIEW" && - current.getWebContentsId() === ${webContents.id}) active.focus({ preventScroll: true }); - }; + let pressed = false; + try { + pressed = active?.tagName === "WEBVIEW" && state.presses.includes(active.getWebContentsId()); + } catch {} + if (!pressed) { + state.saved = active && active !== document.body && active.tagName !== "WEBVIEW" ? active : null; + } + state.presses.push(${webContents.id}); })()`); try { return await debuggee.sendCommand(method, params, sessionId); } finally { if (!host.isDestroyed()) { - await host.executeJavaScript(`globalThis[${key}]?.()`); + // Membership check: a host reload starts a new state without this press. + await host.executeJavaScript(`(() => { + const state = ${HOST_FOCUS}; + const index = state ? state.presses.indexOf(${webContents.id}) : -1; + if (index < 0) return; + state.presses.splice(index, 1); + if (state.presses.length === 0) delete ${HOST_FOCUS}; + const current = document.activeElement; + try { + if (state.saved?.isConnected && current?.tagName === "WEBVIEW" && + current.getWebContentsId() === ${webContents.id}) state.saved.focus({ preventScroll: true }); + } catch {} + })()`); } } }; diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0087444a8412c..c83eae8d8358c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -35,7 +35,7 @@ import { import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -523,7 +523,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; const hostPlatform = yield* HostProcess.Platform; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const parentScope = yield* Scope.Scope; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; const passkeys = yield* PreviewPasskeys.PreviewPasskeys; @@ -2390,7 +2390,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc, ), ]); - const uuid = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const uuid = yield* random.uuidV4(); const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}-${uuid.slice(0, 8)}`; const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`); const data = image.toPNG(); diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 0135225f90c48..ab3e2e4cd0755 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -52,7 +53,11 @@ function layerEnvironment(baseDir: string, appVersion = "0.0.17") { runningUnderArm64Translation: false, }).pipe( Layer.provide( - Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + DesktopConfig.layerTest({ T3CODE_HOME: baseDir }), + ), ), ); } @@ -74,11 +79,11 @@ const withSettings = ( Effect.provide( DesktopAppSettings.layer.pipe( Layer.provideMerge(layerEnvironment(baseDir, options?.appVersion)), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped); function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { return Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a0624ced669ea..aa16502a16585 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -7,7 +7,7 @@ import { import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +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"; @@ -469,7 +469,7 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) => @@ -487,16 +487,8 @@ export const make = Effect.gen(function* () { return Effect.succeed([settingsChange(settings, false), settings] as const); } - return crypto.randomUUIDv4().pipe( + return random.uuidV4().pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError( - (cause) => - new DesktopSettingsWriteError({ - operation: "create-temporary-file-name", - path: environment.desktopSettingsPath, - cause, - }), - ), Effect.flatMap((suffix) => writeSettings({ fileSystem, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ec5062f9105bf..2d89754ac1150 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { @@ -89,13 +90,17 @@ function layer(baseDir: string) { runningUnderArm64Translation: false, }).pipe( Layer.provide( - Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + DesktopConfig.layerTest({ T3CODE_HOME: baseDir }), + ), ), ); return DesktopClientSettings.layer.pipe( Layer.provideMerge(layerEnvironment), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); } @@ -108,7 +113,7 @@ const withClientSettings = ( prefix: "t3-desktop-client-settings-test-", }); return yield* effect.pipe(Effect.provide(layer(baseDir))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped); describe("DesktopClientSettings", () => { it.effect("saves through a symlinked client settings file without replacing the link", () => diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 0836db6678226..52b619494626f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -2,7 +2,7 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +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"; @@ -188,23 +188,15 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; return DesktopClientSettings.of({ get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( Effect.withSpan("desktop.clientSettings.get"), ), set: Effect.fn("desktop.clientSettings.set")(function (settings) { - return crypto.randomUUIDv4().pipe( + return random.uuidV4().pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError( - (cause) => - new DesktopClientSettingsWriteError({ - operation: "create-temporary-file-name", - path: environment.clientSettingsPath, - cause, - }), - ), Effect.flatMap((suffix) => writeClientSettings({ fileSystem, diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 84fcb4cbf8277..721f130ef5e56 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -6,7 +6,7 @@ import { type ClientSettings, type DesktopSnapShotEvent, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -519,11 +519,8 @@ const layerTest = ( FileSystem.layerNoop(fileSystemOverrides), Path.layer, Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ), ), ), @@ -675,11 +672,8 @@ function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean readFileString: (path) => Effect.sync(() => metadata.get(path)!), }), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(++randomByte), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(++randomByte)), ), Layer.succeed( DesktopWindow.DesktopWindow, diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 9dfcdc2f35a8f..a3d4db0bb4dc2 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -23,7 +23,7 @@ import { } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; @@ -750,7 +750,7 @@ export const make = Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const settingsRef = yield* Ref.make(DEFAULT_CLIENT_SETTINGS); const stateRef = yield* Ref.make({ mode: captureMode(environment.platform), @@ -896,7 +896,7 @@ export const make = Effect.gen(function* () { const prepareCapture = Effect.fn("desktop.snapShot.prepareCapture")(function* ( settings: ClientSettings, ) { - const id = yield* crypto.randomUUIDv4().pipe(Effect.mapError((cause) => captureFailure(cause))); + const id = yield* random.uuidV4().pipe(Effect.mapError((cause) => captureFailure(cause))); const mode = captureMode(environment.platform); if (mode === "unavailable") { return yield* new DesktopSnapShotError({ operation: "unsupported", captureId: id }); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index f6c9ee43d7580..3a3e84e03a9b7 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -18,7 +18,7 @@ import { } from "@t3tools/ssh/errors"; import * as SshTunnel from "@t3tools/ssh/tunnel"; import * as Context from "effect/Context"; -import type * as Crypto from "effect/Crypto"; +import type * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -30,7 +30,7 @@ import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; export type DesktopSshEnvironmentRuntimeServices = | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index b86cc275df230..c0f8210f18146 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -107,7 +108,7 @@ function layerElectronWindow(window: ReturnType["window"] function layer(window: ReturnType["window"]) { return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe( Layer.provide(layerElectronWindow(window)), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(TestClock.layer()), ); } diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 7442282aa15ad..3c16b938331ca 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -2,7 +2,7 @@ import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts"; import type { SshPasswordRequest } from "@t3tools/ssh/auth"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -223,7 +223,7 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( options: DesktopSshPasswordPromptsOptions = {}, ) { const electronWindow = yield* ElectronWindow.ElectronWindow; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const pendingRef = yield* Ref.make(new Map()); const passwordPromptTimeoutMs = options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; @@ -307,7 +307,7 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( }); } - const requestId = yield* crypto.randomUUIDv4().pipe( + const requestId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new DesktopSshPromptRequestIdGenerationError({ diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index ec3b1ce9a1d59..226f00f8cba89 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopUpdateState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -288,7 +289,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), ), Layer.provideMerge(layerEnvironment), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); return { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 0dcb2b85ac2c5..4f8949823e354 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -549,6 +549,79 @@ describe("DesktopWindow", () => { }), ); + it.effect.each(["before", "after"] as const)( + "keeps a closed startup window closed until activation %s backend readiness", + (activation) => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + yield* windows.ensureMain; + fake.windowListeners.get("ready-to-show")?.(); + fake.isDestroyed.mockReturnValue(true); + fake.windowListeners.get("closed")?.(); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + if (activation === "after") { + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + } + fake.isDestroyed.mockReturnValue(false); + yield* windows.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); + + it.effect.each(["renderer", "backend"] as const)( + "reveals the painted boot shell when %s becomes ready first", + (first) => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const reveal = vi.fn(); + const painted = Promise.withResolvers(); + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + yield* windows.ensureMain; + assert.equal(fake.loadURL.mock.calls.length, 1); + yield* windows.activate; + yield* windows.revealOrCreateMain; + yield* windows.dispatchMenuAction("open-settings"); + assert.equal(reveal.mock.calls.length, 0); + if (first === "backend") { + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(reveal.mock.calls.length, 0); + } + fake.windowListeners.get("ready-to-show")?.(); + yield* Effect.promise(() => painted.promise); + assert.equal(reveal.mock.calls.length, 1); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + assert.equal(reveal.mock.calls.length, 1); + yield* windows.handleBackendNotReady; + yield* windows.activate; + assert.equal(reveal.mock.calls.length, 2); + }).pipe( + Effect.provide( + layerTest({ + window: fake.window, + createCount, + mainWindow, + onReveal: () => { + reveal(); + painted.resolve(); + }, + }), + ), + ); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1133,6 +1206,10 @@ describe("DesktopWindow", () => { yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + fakeWindow.windowListeners.get("ready-to-show")?.(); + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + operations.length = 0; + foreground = "Discord"; yield* desktopWindow.dispatchSnapShotEvent({ type: "started", id: captureOne }); assert.equal(foreground, "T3 Code"); foreground = "Explorer"; diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 10801089841d1..5e02f4cfd8bbe 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Electron from "electron"; @@ -306,10 +307,17 @@ export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; const backendReadyRef = yield* Ref.make(false); + let backendHasStarted = false; + let mainClosedDuringStartup = false; const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + const mainWindowMutex = yield* Semaphore.make(1); + const pendingMainReveals = new WeakMap>(); + const revealMain = Effect.fn("desktop.window.revealMain")((window: Electron.BrowserWindow) => + Effect.suspend(() => pendingMainReveals.get(window) ?? electronWindow.reveal(window)), + ); let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { @@ -407,6 +415,7 @@ export const make = Effect.gen(function* () { return Option.none(); } + pendingMainReveals.set(window, Effect.void); yield* rendererHistory.register(window.webContents, { surface: "main" }); if (environment.platform === "darwin") { window.setAutoHideCursor(false); @@ -822,14 +831,27 @@ export const make = Effect.gen(function* () { if (environment.platform === "linux") { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } + let rendererReady = false; + let firstRevealComplete = false; + const revealWhenReady = Effect.gen(function* () { + if ( + !rendererReady || + firstRevealComplete || + window.isDestroyed() || + (yield* Ref.get(desktopState.quitting)) + ) + return; + firstRevealComplete = true; + pendingMainReveals.delete(window); + window.webContents.setBackgroundThrottling(true); + if (persistedSettings.mainWindowMaximized) window.maximize(); + yield* electronWindow.reveal(window); + yield* dismissConnectingSplash; + }); + pendingMainReveals.set(window, revealWhenReady); bindFirstRevealTrigger(revealSubscribers, () => { - if (!window.isDestroyed()) { - window.webContents.setBackgroundThrottling(true); - } - if (persistedSettings.mainWindowMaximized) { - window.maximize(); - } - void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); + rendererReady = true; + void runPromise(revealWhenReady); }); loadApplication(); @@ -838,6 +860,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + if (!backendHasStarted) mainClosedDuringStartup = true; clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); @@ -850,6 +873,7 @@ export const make = Effect.gen(function* () { const window = yield* createWindow(); if (Option.isSome(window)) { yield* electronWindow.setMain(window.value); + mainClosedDuringStartup = false; yield* logWindowInfo("main window created"); } return window; @@ -862,7 +886,7 @@ export const make = Effect.gen(function* () { return existingWindow; } return yield* createMain; - }).pipe(Effect.withSpan("desktop.window.ensureMain")); + }).pipe(mainWindowMutex.withPermits(1), Effect.withSpan("desktop.window.ensureMain")); const requireWindow = (window: Option.Option) => Option.match(window, { @@ -872,7 +896,7 @@ export const make = Effect.gen(function* () { const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain.pipe(Effect.flatMap(requireWindow)); - yield* electronWindow.reveal(window); + yield* revealMain(window); return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); @@ -884,9 +908,7 @@ export const make = Effect.gen(function* () { const createMainIfBackendReady = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return; if (yield* waitingForBackend) return; - const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) return; - yield* createMain; + yield* ensureMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { @@ -956,7 +978,7 @@ export const make = Effect.gen(function* () { }); // Send before best-effort reveal; another process may refuse to yield foreground focus. const dispatch = reveal - ? send.pipe(Effect.andThen(electronWindow.reveal(targetWindow).pipe(Effect.ignoreCause))) + ? send.pipe(Effect.andThen(revealMain(targetWindow).pipe(Effect.ignoreCause))) : send; if (targetWindow.webContents.isLoadingMainFrame()) { targetWindow.webContents.once("did-finish-load", () => void runPromise(dispatch)); @@ -971,7 +993,7 @@ export const make = Effect.gen(function* () { revealOrCreateMain, prepareCaptureReveal: Effect.gen(function* () { const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) { + if (Option.isSome(existingWindow) && !pendingMainReveals.has(existingWindow.value)) { yield* electronWindow.prepareReveal(existingWindow.value); } }), @@ -979,7 +1001,11 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { - yield* electronWindow.reveal(existingWindow.value); + yield* revealMain(existingWindow.value); + return; + } + if (mainClosedDuringStartup) { + yield* ensureMain; return; } const backendReady = yield* Ref.get(backendReadyRef); @@ -995,9 +1021,10 @@ export const make = Effect.gen(function* () { createMainIfBackendReady, showConnectingSplash, handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { + backendHasStarted = true; yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - yield* createMainIfBackendReady; + if (!mainClosedDuringStartup) yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 5cd1337d141f4..c4cc8aca59f22 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -6,7 +6,25 @@ export const IOS_APP_STORE_URL = export const ANDROID_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.t3tools.t3code"; +// Runs at build time, so each deploy picks up the current count. +async function fetchGithubStars(): Promise { + const token = process.env.GITHUB_TOKEN; + try { + const response = await fetch("https://api.github.com/repos/pingdotgg/t3code", { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!response.ok) throw new Error(`GitHub API responded ${response.status}`); + const { stargazers_count: stars } = (await response.json()) as { stargazers_count: number }; + return stars < 1000 ? String(stars) : `${Math.floor(stars / 1000)}k+`; + } catch (error) { + // A deploy must not ship a made-up count; offline dev should still render. + if (import.meta.env.PROD) throw new Error("Could not fetch GitHub stars", { cause: error }); + console.warn("Could not fetch GitHub stars", error); + return "?"; + } +} + export const MARKETING_STATS = { - githubStars: "31k+", + githubStars: await fetchGithubStars(), users: "500,000", } as const; diff --git a/apps/mobile/scripts/measure-renders.mts b/apps/mobile/scripts/measure-renders.mts index 2e517f1f7bbd0..f49938fff8601 100644 --- a/apps/mobile/scripts/measure-renders.mts +++ b/apps/mobile/scripts/measure-renders.mts @@ -1,8 +1,9 @@ // See docs/internals/mobile-development.md#measuring-renders. import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import * as NodeWS from "@effect/platform-node/NodeWS"; import { CommandId, EnvironmentId, @@ -17,7 +18,7 @@ import { type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as Console from "effect/Console"; -import * as Crypto from "effect/Crypto"; +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"; @@ -71,7 +72,7 @@ interface Options { readonly projectRoot: string; } -const uuid = Effect.flatMap(Crypto.Crypto, (crypto) => crypto.randomUUIDv4()); +const uuid = Effect.flatMap(SecureRandom.SecureRandom, (random) => random.uuidV4()); const median = (values: ReadonlyArray) => { const sorted = [...values].sort((a, b) => a - b); @@ -398,8 +399,9 @@ Command.run(command, { version: "0.0.0" }).pipe( Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, NodeHttpClient.layerUndici, - NodeSocket.layerWebSocketConstructorWS, + NodeWS.layerWebSocketConstructor, ), ), NodeRuntime.runMain, diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index d107d9be5d8a0..13aeb462bdf5f 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -17,12 +17,14 @@ import { resolveScaledTextRole } from "../lib/appearancePreferences"; export function ScrollComposeButton(props: { readonly expanded: boolean; readonly onPress: () => void; + readonly size?: number; readonly className?: string; readonly style?: StyleProp; }) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); - const { iconSize, fabSize } = useAndroidControlSizing(); + const { iconSize, fabSize: scaledFabSize } = useAndroidControlSizing(); + const fabSize = props.size ?? scaledFabSize; const nativeSize = Math.max(56, fabSize); const scale = fabSize / nativeSize; const nativeIconSize = Math.round(iconSize / scale); diff --git a/apps/mobile/src/components/ui/Button.tsx b/apps/mobile/src/components/ui/Button.tsx index 05c6e0085245f..bb187f66713d3 100644 --- a/apps/mobile/src/components/ui/Button.tsx +++ b/apps/mobile/src/components/ui/Button.tsx @@ -14,6 +14,7 @@ export interface ButtonProps { readonly loading?: boolean; readonly tone?: "primary" | "secondary" | "danger" | "text" | "contrast"; readonly fullWidth?: boolean; + readonly size?: "default" | "compact"; } const TONE_CLASS_NAMES = { @@ -36,7 +37,8 @@ export function Button(props: ButtonProps) { disabled={disabled} onPress={props.onPress} className={cn( - "min-h-12 flex-row items-center justify-center gap-2 rounded-full px-6 active:opacity-70", + "flex-row items-center justify-center gap-2 rounded-full active:opacity-70", + props.size === "compact" ? "min-h-10 px-5" : "min-h-12 px-6", props.fullWidth ? "w-full" : "self-start", disabled ? "bg-subtle-strong" : containerClassName, )} diff --git a/apps/mobile/src/components/ui/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index 2483ac9fa1285..49efbdf5203d8 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -1,3 +1,4 @@ +import { cn } from "../../lib/cn"; import { MOBILE_MOTION, MOBILE_SHAPE } from "../../lib/designTokens"; import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { AccessibilityInfo, Platform, StyleSheet, View, useWindowDimensions } from "react-native"; @@ -45,7 +46,7 @@ export function Toast(props: ToastProps) { function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) { const { fontScale } = useWindowDimensions(); const { colorScheme } = useControlTheme(); - const borderRadius = Platform.OS === "ios" ? 31 : MOBILE_SHAPE.toast; + const borderRadius = Platform.OS === "ios" ? 28 : MOBILE_SHAPE.toast; // Explicit single-line notices reserve the action's width and truncate the text. const inlineAction = props.singleLine || (!props.description && fontScale < 1.5); const message = @@ -53,7 +54,11 @@ function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) { ? `${props.message}: ${props.description}` : props.message; const action = props.action ? ( - `); + const failure = (selector: string) => + ServerBrowserPage.click(page, { selector, timeoutMs: 500 }).then( + () => expect.unreachable(), + ServerBrowserPage.toOperationError, + ); + + const absent = await failure(".definitely-no-such-t3-target"); + expect(absent.tag).toBe("PreviewAutomationTimeoutError"); + expect(absent.message).toBe( + 'The click may have happened and removed its target or navigated; check the page before retrying. No element matches ".definitely-no-such-t3-target" after the timeout; take a fresh snapshot and use its locator.', + ); + expect(absent.cause).toMatchObject({ name: "TimeoutError" }); + 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.', + ); + }); + it("does not retarget a removed ref to a replacement node", async () => { await page.setContent(""); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "original"); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index d573cd6387c8b..f128bf445e603 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -33,8 +33,8 @@ export class ServerBrowserOperationError extends Error { readonly tag: string; readonly detail: unknown; - constructor(tag: string, message: string, detail?: unknown) { - super(message); + constructor(tag: string, message: string, detail?: unknown, options?: ErrorOptions) { + super(message, options); this.tag = tag; this.detail = detail; } @@ -72,6 +72,8 @@ export const toOperationError = ( }; const DEFAULT_TIMEOUT_MS = 15_000; +const TARGET_COUNT_MS = 250; +const MAX_TARGET_CHARS = 200; const pageRefs = new WeakMap }>(); // A compact runtime namespace prevents old refs from aliasing after a server restart. @@ -634,6 +636,58 @@ export const click = async ( ): Promise<{ readonly x: number; readonly y: number }> => { const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const locator = targetLocator(page, input); + try { + return await clickTarget(page, locator, input, timeout, pointer); + } catch (cause) { + throw locator === null ? cause : await namedTargetTimeout(locator, input, cause); + } +}; + +/** + * Playwright's timeout names neither the target nor why it waited. Counting only after the + * wait failed keeps auto-wait intact, but the count describes the page after the timeout, not + * what the click did: Playwright can dispatch the click and then time out waiting for the + * navigation it started. + */ +const namedTargetTimeout = async ( + locator: Locator, + input: { readonly locator?: string | undefined; readonly selector?: string | undefined }, + cause: unknown, +) => { + if (!(cause instanceof Error && cause.name === "TimeoutError")) return cause; + const selector = JSON.stringify(input.locator ?? input.selector); + // The warning leads and the target is capped, so a reply cut short still carries the warning. + const target = + selector.length > MAX_TARGET_CHARS ? `${selector.slice(0, MAX_TARGET_CHARS)}...` : selector; + // The host's reply must still beat the broker's short grace after the operation timeout. + let timer: ReturnType | undefined; + const matches = await Promise.race([ + locator.count().catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), TARGET_COUNT_MS); + }), + ]).finally(() => clearTimeout(timer)); + const reason = + matches === undefined + ? `The click may have happened; check the page before retrying. Whether ${target} still matches an element is unknown.` + : 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.`; + return new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + reason, + { reason }, + { cause }, + ); +}; + +const clickTarget = async ( + page: Page, + locator: Locator | null, + input: PreviewAutomationClickInput, + timeout: number, + pointer: PointerReporter, +) => { const point = await targetPoint(page, locator, input, timeout); await pointer(point, "click"); const options = { button: input.button ?? "left", clickCount: input.clickCount ?? 1 } as const; diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index 5f2c685fa685c..c4a709ed8acc4 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -34,7 +34,7 @@ const makeAuth = ( const requests: string[] = []; const layer = Layer.mock(EnvironmentAuth.EnvironmentAuth, { authenticateWebSocketUpgrade: (request) => { - requests.push(request.originalUrl); + requests.push(request.url); return error ? Effect.fail(error) : Effect.succeed({ diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index 8d8164cd911c4..e46a0c76adb53 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -24,6 +24,7 @@ import { authenticateMediaRequest } from "../auth/http.ts"; import { assetResponseHeaders } from "../http.ts"; import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; import * as ServerBrowser from "./ServerBrowser.ts"; +import { requestUrl } from "../requestUrl.ts"; const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream"; const TAB_GONE_CODE = 4404; @@ -53,7 +54,7 @@ const parseMessage = (chunk: Uint8Array | string): unknown => { // Request middleware owns tracing, including long-lived WebSocket sessions. const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBrowser["Service"]) { const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); + const url = requestUrl(request); if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/download`) { return yield* serveDownload(browser, url.value.searchParams); diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index e086ad854b3fb..f47be6319cb81 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -9,14 +9,14 @@ describe("SessionControl", () => { const started = Promise.withResolvers(); const finish = Promise.withResolvers(); const events: Array = []; - const running = control.humanLatest("viewer", "hover:0", async () => { + const running = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("running"); started.resolve(); await finish.promise; }); await started.promise; const moves = Array.from({ length: 120 }, (_, position) => - control.humanLatest("viewer", "hover:0", async () => { + control.humanLatest("viewer", "hover:0", undefined, async () => { events.push(position); }), ); @@ -36,7 +36,7 @@ describe("SessionControl", () => { const finish = Promise.withResolvers(); const events: string[] = []; const running = control.human("viewer", () => finish.promise); - const before = control.humanLatest("viewer", "hover:0", async () => { + const before = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("before"); }); const recordBarrier = async () => { @@ -44,11 +44,11 @@ describe("SessionControl", () => { }; const between = barrier === "modifiers" - ? control.humanLatest("viewer", "hover:2", recordBarrier) + ? control.humanLatest("viewer", "hover:2", undefined, recordBarrier) : barrier === "system" ? control.system(recordBarrier) : control.human("viewer", recordBarrier); - const after = control.humanLatest("viewer", "hover:0", async () => { + const after = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("after"); }); finish.resolve(); @@ -63,15 +63,15 @@ describe("SessionControl", () => { const finish = Promise.withResolvers(); const events: string[] = []; const running = control.human("viewer", () => finish.promise); - const stale = control.humanLatest("viewer", "hover", async () => { + const stale = control.humanLatest("viewer", "hover", undefined, async () => { events.push("stale"); }); await expect( - control.humanLatest("other", "hover", async () => { + control.humanLatest("other", "hover", undefined, async () => { events.push("other"); }), ).rejects.toThrow(BrowserControlInterrupted); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); finish.resolve(); @@ -93,16 +93,16 @@ describe("SessionControl", () => { }); await started.promise; const stale = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("stale"); }), ).rejects.toThrow(BrowserControlInterrupted); const released = control[handoff]("viewer"); const retaken = control.take("viewer"); - const fresh = control.humanLatest("viewer", "hover", async () => { + const fresh = control.humanLatest("viewer", "hover", undefined, async () => { events.push("fresh"); }); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); finish.resolve(); @@ -124,14 +124,14 @@ describe("SessionControl", () => { await started.promise; const waiting = [1, 2].map((position) => expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push(`hover ${position}`); }), ).rejects.toThrow("closed"), ); const closed = control.close(); await expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("after close"); }), ).rejects.toThrow("closed"); @@ -147,18 +147,18 @@ describe("SessionControl", () => { const events: string[] = []; const running = control.human("viewer", () => finish.promise); const first = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("superseded"); }), ).rejects.toThrow("dispatch failed"); const last = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { throw new Error("dispatch failed"); }), ).rejects.toThrow("dispatch failed"); finish.resolve(); await Promise.all([running, first, last]); - await control.humanLatest("viewer", "hover", async () => { + await control.humanLatest("viewer", "hover", undefined, async () => { events.push("recovered"); }); expect(events).toEqual(["recovered"]); @@ -177,10 +177,10 @@ describe("SessionControl", () => { ); return "started"; }); - const first = control.humanLatest("viewer", "hover", async () => { + const first = control.humanLatest("viewer", "hover", undefined, async () => { events.push("superseded"); }); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); await expect(navigation).resolves.toBe("started"); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index cff65d3379a02..57644e3be7135 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -23,7 +23,7 @@ export class SessionControl { private readonly onGenerationChange: () => void; private tail: Promise = Promise.resolve(); private latestHuman: { - entry: { viewerId: string; key: string; run: () => Promise }; + entry: { viewerId: string; key: string; input: unknown; run: (input: never) => Promise }; result: Promise; } | null = null; private pending: Promise = Promise.resolve(); @@ -134,18 +134,28 @@ export class SessionControl { return this.action(() => this.owner === viewerId, run); } - /** Replace only adjacent waiting work; the running action and intervening actions keep their order. */ - humanLatest(viewerId: string, key: string, run: () => Promise): Promise { - if (this.closed || this.owner !== viewerId) return this.human(viewerId, run); + /** + * Merge into only adjacent waiting work with the same key; the running action and + * intervening actions keep their order. Work sharing a key must share its input type. + */ + humanLatest( + viewerId: string, + key: string, + input: A, + run: (input: A) => Promise, + merge: (waiting: A, next: A) => A = (_waiting, next) => next, + ): Promise { + if (this.closed || this.owner !== viewerId) return this.human(viewerId, () => run(input)); const latest = this.latestHuman; if (latest?.entry.viewerId === viewerId && latest.entry.key === key) { + latest.entry.input = merge(latest.entry.input as A, input); latest.entry.run = run; return latest.result; } - const entry = { viewerId, key, run }; + const entry = { viewerId, key, input, run }; const result = this.human(viewerId, async () => { if (this.latestHuman?.entry === entry) this.latestHuman = null; - await entry.run(); + await entry.run(entry.input); }); this.latestHuman = { entry, result }; return result; diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 2a9998f4cfbf6..0095146a3021f 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -127,8 +127,11 @@ const withFakeSystemd = ( ), ); -const unitOf = (launch: { readonly args: ReadonlyArray }) => - launch.args.find((arg) => arg.startsWith("--unit="))?.slice("--unit=".length) ?? ""; +const unitOf = (launch: Pick) => + launch.args + .map(String) + .find((arg) => arg.startsWith("--unit=")) + ?.slice("--unit=".length) ?? ""; const sliceOf = (launch: { readonly args: ReadonlyArray }) => launch.args.find((arg) => arg.startsWith("--slice="))?.slice("--slice=".length) ?? ""; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1b724c17010d8..9f975ee209e3d 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -302,7 +302,7 @@ it.effect.skipIf(windowsHost)( assert.ok(spawned); assert.equal(spawned.command, "powershell.exe"); assert.equal(spawned.options.detached, true); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const encodedCommand = String(spawned.args[spawned.args.length - 1] ?? ""); const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); assert.equal( decodedCommand, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index d71dc0f0d8fa8..4b0ffd8fec2f6 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1,4 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - FIFO gates must be released with a synchronous +// open on the main thread, because the Effect FileSystem queues behind the pinned libuv pool. import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import { describe, expect, it } from "@effect/vitest"; import { @@ -9,9 +13,11 @@ import { ProviderInstanceId, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -248,6 +254,42 @@ const makeFileIdentityFixture = Effect.fn("AgentSessionScanner.test.makeFileIden }, ); +const LIBUV_POOL_SIZE = Number(process.env.UV_THREADPOOL_SIZE ?? 4); + +/** + * Opening a FIFO for reading waits in `open()` until a writer appears, like a + * TCC-protected file in a headless macOS daemon, and pins one libuv pool thread + * that fiber interruption cannot free. The finalizer opens it for writing on the + * main thread, which releases every waiting reader even with the pool exhausted. + */ +const makeOpenGate = Effect.fn("AgentSessionScanner.test.makeOpenGate")(function* ( + gatePath: string, +) { + NodeChildProcess.execFileSync("mkfifo", [gatePath]); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + try { + NodeFS.closeSync( + NodeFS.openSync(gatePath, NodeFS.constants.O_WRONLY | NodeFS.constants.O_NONBLOCK), + ); + } catch { + // ENXIO: no reader is waiting. + } + }), + ); +}); + +/** + * Resolves once a reader has opened the FIFO, so its read deadline is already + * scheduled on the TestClock. The writer stays open until the scope closes, so + * the reader sees no EOF and has to wait out that deadline. + */ +const awaitGateReader = (gatePath: string) => + Effect.acquireRelease( + Effect.promise(() => NodeFS.promises.open(gatePath, NodeFS.constants.O_WRONLY)), + (handle) => Effect.promise(() => handle.close()), + ); + it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { describe("scan", () => { it.effect("reads Claude project cwds from transcripts, newest first", () => @@ -1463,9 +1505,11 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); - for (const [sessionId, padding, mtimeMs] of [ - ["excessive", "\n".repeat(100_001), nowMs], - ["older", "", nowMs - 1_000], + const exactLimit = makeRecordLimitTranscript(workspace, false); + // The newer transcript has 100,001 records; the older one has exactly 100,000. + for (const [sessionId, contents, mtimeMs] of [ + ["excessive", exactLimit.replace("record-limit-session", "excessive") + "{}\n", nowMs], + ["older", exactLimit, nowMs - 1_000], ] as const) { yield* writeTranscript({ filePath: path.join( @@ -1476,18 +1520,56 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { "24", `rollout-${sessionId}.jsonl`, ), + contents, + mtimeMs, + }); + } + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); + expect(outcomes[1]).toMatchObject({ + thread: { providerSessionId: "record-limit-session" }, + }); + }), + ); + + it.effect("imports every transcript that fits the per-file record limit", () => + 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-shared-records-claude-"); + const codexHomePath = yield* makeTempDir("t3code-shared-records-codex-"); + const workspace = yield* makeTempDir("t3code-shared-records-workspace-"); + // Together these exceed 100,000 records, but each one is individually valid. + const recordCounts = [20_000, 20_000, 20_000, 58_000]; + for (const [index, recordCount] of recordCounts.entries()) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${index}.jsonl`, + ), contents: [ encodeTranscriptRecord({ type: "session_meta", - payload: { id: sessionId, cwd: workspace }, + payload: { id: `session-${index}`, cwd: workspace }, }), encodeTranscriptRecord({ type: "event_msg", payload: { type: "user_message", message: "Imported prompt" }, }), - ].join("\n") + padding, - mtimeMs, + ].join("\n") + + "\n" + + "{}\n".repeat(recordCount - 2), + mtimeMs: nowMs - index * 1_000, }); } const outcomes = yield* runRecentThreadOutcomes({ @@ -1495,8 +1577,11 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { codexHomePath, workspaceRoot: workspace, }); - expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); - expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); + expect( + outcomes.map((outcome) => + outcome._tag === "Importable" ? outcome.thread.providerSessionId : outcome._tag, + ), + ).toEqual(["session-0", "session-1", "session-2", "session-3"]); }), ); @@ -1862,6 +1947,255 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); }); +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "AgentSessionScanner scan with a git config open() that never returns", + () => { + it.effect( + "completes every scan and leaves the libuv pool free when .git/config is a FIFO", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-fifo-config-"); + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + yield* makeOpenGate(path.join(repo, ".git", "config")); + const transcriptPath = path.join(claudeHomePath, "projects", "-fifo", "a.jsonl"); + yield* writeTranscript({ + filePath: transcriptPath, + contents: claudeSessionLine(repo), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + for (let attempt = 1; attempt <= LIBUV_POOL_SIZE + 1; attempt++) { + const scan = yield* Effect.forkChild( + runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.map(({ candidates }) => candidates.map(({ path }) => path)), + ), + ); + yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(path.join(repo, ".git", "config")); + yield* TestClock.adjust("2 seconds"); + expect(yield* Fiber.join(scan), `scan ${attempt}`).toEqual([repo]); + }), + ); + } + // Hangs if the scans left libuv pool threads stuck in open(). + yield* fileSystem.readFileString(transcriptPath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // Real cat completions must not race a virtual jump past every read's deadline. + it.live("still reads the git identity of repositories next to a blocked one", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repos = [ + yield* makeTempDir("t3code-workspace-before-"), + yield* makeTempDir("t3code-workspace-blocked-"), + yield* makeTempDir("t3code-workspace-after-"), + ] as const; + for (const [index, repo] of repos.entries()) { + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + const configPath = path.join(repo, ".git", "config"); + if (index === 1) yield* makeOpenGate(configPath); + else + yield* fileSystem.writeFileString( + configPath, + `[remote "origin"]\n\turl = git@github.com:pingdotgg/repo-${index}.git\n`, + ); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(repo), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + for (let attempt = 1; attempt <= 2; attempt++) { + const scan = yield* Effect.forkChild(runScan({ claudeHomePath, codexHomePath })); + const result = yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(path.join(repos[1], ".git", "config")); + return yield* Fiber.join(scan); + }), + ); + expect( + result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })), + `scan ${attempt}`, + ).toEqual([ + { + path: repos[2], + git: { remoteKey: "github.com/pingdotgg/repo-2", repository: "pingdotgg/repo-2" }, + }, + { path: repos[1], git: { remoteKey: null, repository: null } }, + { + path: repos[0], + git: { remoteKey: "github.com/pingdotgg/repo-0", repository: "pingdotgg/repo-0" }, + }, + ]); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reads every blocked git config of a scan under one deadline", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const root = yield* makeTempDir("t3code-workspace-blocked-configs-"); + const repos = [path.join(root, "repo-a"), path.join(root, "repo-b")]; + for (const repo of repos) { + yield* fileSystem.makeDirectory(path.join(repo, ".git"), { recursive: true }); + yield* makeOpenGate(path.join(repo, ".git", "config")); + } + // Two linked worktrees of one bare repository share its blocked config. + const bareGitDir = path.join(root, "bare.git"); + yield* fileSystem.makeDirectory(bareGitDir); + yield* makeOpenGate(path.join(bareGitDir, "config")); + const worktrees = [path.join(root, "wt-1"), path.join(root, "wt-2")]; + for (const worktree of worktrees) { + const gitDir = path.join(bareGitDir, "worktrees", path.basename(worktree)); + yield* fileSystem.makeDirectory(gitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(gitDir, "commondir"), "../..\n"); + yield* fileSystem.makeDirectory(worktree); + yield* fileSystem.writeFileString(path.join(worktree, ".git"), `gitdir: ${gitDir}\n`); + } + for (const [index, cwd] of [...repos, ...worktrees].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + const scan = yield* Effect.forkChild( + runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.map(({ candidates }) => candidates.map(({ path }) => path).toSorted()), + ), + ); + // Every blocked config is open before any deadline passes; reading them + // one after another would leave the later ones unopened. + yield* Effect.forEach( + [...repos.map((repo) => path.join(repo, ".git")), bareGitDir], + (gitDir) => awaitGateReader(path.join(gitDir, "config")), + { concurrency: "unbounded" }, + ); + yield* TestClock.adjust("2 seconds"); + expect(yield* Fiber.join(scan)).toEqual(repos); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("completes every scan when a .git pointer file or commondir never opens", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const root = yield* makeTempDir("t3code-workspace-blocked-pointers-"); + const pointerCheckout = path.join(root, "pointer"); + yield* fileSystem.makeDirectory(pointerCheckout); + yield* makeOpenGate(path.join(pointerCheckout, ".git")); + const worktree = path.join(root, "worktree"); + const worktreeGitDir = path.join(root, "main", ".git", "worktrees", "worktree"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* makeOpenGate(path.join(worktreeGitDir, "commondir")); + yield* fileSystem.makeDirectory(worktree); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${worktreeGitDir}\n`, + ); + for (const [index, cwd] of [pointerCheckout, worktree].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + // One scanner across scans: later scans skip the files that blocked. + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scanPaths = scanner.scan.pipe( + Effect.map(({ candidates }) => candidates.map(({ path }) => path)), + ); + const first = yield* Effect.forkChild(scanPaths); + // Pointer files are read first, then the commondir files they lead to. + for (const gatePath of [ + path.join(pointerCheckout, ".git"), + path.join(worktreeGitDir, "commondir"), + ]) { + yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(gatePath); + yield* TestClock.adjust("2 seconds"); + }), + ); + } + expect(yield* Fiber.join(first)).toEqual([pointerCheckout]); + for (let attempt = 2; attempt <= LIBUV_POOL_SIZE + 1; attempt++) { + expect(yield* scanPaths, `scan ${attempt}`).toEqual([pointerCheckout]); + } + }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + // Hangs if the scans left libuv pool threads stuck in open(). + yield* fileSystem.readFileString(path.join(worktree, ".git")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reads a once-blocked git config again after the retry interval", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-unblocked-config-"); + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + const configPath = path.join(repo, ".git", "config"); + yield* makeOpenGate(configPath); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-unblocked", "a.jsonl"), + contents: claudeSessionLine(repo), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + const gitOfScan = (scan: AgentSessionScanner.AgentSessionScanner["Service"]["scan"]) => + scan.pipe(Effect.map(({ candidates }) => candidates.map(({ git }) => git))); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const blockedScan = yield* Effect.forkChild(gitOfScan(scanner.scan)); + yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(configPath); + yield* TestClock.adjust("2 seconds"); + expect(yield* Fiber.join(blockedScan)).toEqual([ + { remoteKey: null, repository: null }, + ]); + }), + ); + + yield* fileSystem.remove(configPath); + yield* fileSystem.writeFileString( + configPath, + `[remote "origin"]\n\turl = git@github.com:pingdotgg/unblocked.git\n`, + ); + expect(yield* gitOfScan(scanner.scan), "a blocked path is skipped for a while").toEqual([ + { remoteKey: null, repository: null }, + ]); + + yield* TestClock.adjust("10 minutes"); + expect(yield* gitOfScan(scanner.scan)).toEqual([ + { remoteKey: "github.com/pingdotgg/unblocked", repository: "pingdotgg/unblocked" }, + ]); + }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + }, +); + describe("parseAgentSessionTranscript", () => { it.each([false, true])( "handles the exact record limit and an interior blank overflow=%s", diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 2c938bca4cde5..271b3093fd5c0 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -14,6 +14,7 @@ import { type AgentSessionScanResult, type ProviderInstanceConfig, } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -31,6 +32,7 @@ import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -65,6 +67,21 @@ const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORT_TRANSCRIPTS = 100; const MAX_IMPORT_RECORDS = 100_000; +/** + * open() of a file under macOS privacy protection waits on a consent prompt + * that a headless server never gets, and a FIFO waits for a writer. Either + * pins a libuv pool thread that interruption cannot free, so git files of + * arbitrary transcript cwds are read by a `cat` child we can kill instead. + */ +const GIT_FILE_READ_DEADLINE = "2 seconds"; +const MAX_GIT_FILE_BYTES = 1024 * 1024; +// Reads run concurrently, so each phase waits one deadline per batch of this +// many blocked files rather than one per blocked file. +const GIT_FILE_READ_CONCURRENCY = 16; +// A blocked path is skipped for a while, then read again in case it was fixed. +const BLOCKED_GIT_FILE_RETRY_MS = 5 * 60 * 1000; +const MAX_BLOCKED_GIT_FILE_PATHS = 1024; + const TranscriptContentBlock = Schema.Struct({ type: Schema.optional(Schema.String), text: Schema.optional(Schema.String), @@ -719,6 +736,7 @@ function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdent /** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const processRunner = yield* ProcessRunner.ProcessRunner; // Concurrent client imports must not hold multiple selected-history budgets. const importReadLock = yield* Semaphore.make(1); const path = yield* Path.Path; @@ -731,7 +749,8 @@ export const make = Effect.gen(function* () { const worktreesDir = yield* fileSystem .realPath(path.resolve(serverConfig.worktreesDir)) .pipe(Effect.orElseSucceed(() => path.resolve(serverConfig.worktreesDir))); - const foldWorktreeCase = (yield* HostProcess.Platform) === "win32"; + const isWindows = (yield* HostProcess.Platform) === "win32"; + const foldWorktreeCase = isWindows; const hostEnvironment = yield* HostProcess.Environment; const homeDir = yield* HostProcess.HomeDirectory; // `/private/tmp` is what macOS reports for sessions started in `/tmp`. @@ -778,48 +797,122 @@ export const make = Effect.gen(function* () { return `path:${normalizeProjectPathForComparison(realPath)}`; }); - const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( - directory: string, - ): Effect.fn.Return< - | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } - | { readonly _tag: "Worktree" } - | { readonly _tag: "NotGit" } - > { - const gitPath = path.join(directory, ".git"); - const gitStats = yield* statOption(gitPath); - if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const; - let gitDir = gitPath; - if (gitStats.value.type !== "Directory") { - const pointer = yield* fileSystem - .readFileString(gitPath) - .pipe(Effect.orElseSucceed(() => "")); - const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); - if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; - gitDir = path.resolve(directory, target); + // Paths whose own read outlived the deadline, mapped to when they may be read + // again. Without this every later scan would wait out the deadline again for + // each phase that touches them. + const blockedGitFilePaths = new Map(); + + /** Reads small files without the libuv pool; "" when missing, unreadable or blocked. */ + const readGitFiles = Effect.fn("AgentSessionScanner.readGitFiles")(function* ( + filePaths: ReadonlyArray, + ) { + const unique = Array.from(new Set(filePaths)); + const now = yield* Clock.currentTimeMillis; + const contents = yield* Effect.forEach( + unique, + (filePath) => { + // No privacy prompts or FIFOs to block open() here. + if (isWindows) { + return fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + } + if ((blockedGitFilePaths.get(filePath) ?? 0) > now) return Effect.succeed(""); + blockedGitFilePaths.delete(filePath); + return processRunner + .run({ + command: "cat", + args: ["--", filePath], + timeout: GIT_FILE_READ_DEADLINE, + timeoutBehavior: "timedOutResult", + maxOutputBytes: MAX_GIT_FILE_BYTES, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => { + if (result.timedOut) { + if (blockedGitFilePaths.size >= MAX_BLOCKED_GIT_FILE_PATHS) { + blockedGitFilePaths.delete(blockedGitFilePaths.keys().next().value!); + } + blockedGitFilePaths.set(filePath, now + BLOCKED_GIT_FILE_RETRY_MS); + } + return result.code === 0 ? result.stdout : ""; + }), + Effect.orElseSucceed(() => ""), + ); + }, + { concurrency: isWindows ? 1 : GIT_FILE_READ_CONCURRENCY }, + ); + return new Map(unique.map((filePath, index) => [filePath, contents[index]!])); + }); + + /** Reads every directory's git identity in three phases of concurrent reads. */ + const readGitIdentities = Effect.fn("AgentSessionScanner.readGitIdentities")(function* ( + directories: ReadonlyArray, + ) { + type GitIdentity = + | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } + | { readonly _tag: "Worktree" } + | { readonly _tag: "NotGit" }; + const identities = new Map(); + const repositories: Array<{ directory: string; gitDir: string; linkedWorktree: boolean }> = []; + 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 }); + } else pointers.push({ directory, gitPath }); } - const linkedWorktree = - gitStats.value.type !== "Directory" && /[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir); - if (linkedWorktree) { - const commonDir = yield* fileSystem - .readFileString(path.join(gitDir, "commondir")) - .pipe(Effect.orElseSucceed(() => "")); - if (commonDir.trim().length === 0) return { _tag: "Worktree" } as const; - gitDir = path.resolve(gitDir, commonDir.trim()); + + const pointerTexts = yield* readGitFiles(pointers.map(({ gitPath }) => gitPath)); + const linkedWorktrees: Array<{ directory: string; gitDir: string }> = []; + for (const { directory, gitPath } of pointers) { + const target = /^gitdir:\s*(.+)$/m.exec(pointerTexts.get(gitPath) ?? "")?.[1]?.trim(); + if (target === undefined || target.length === 0) { + identities.set(directory, { _tag: "NotGit" }); + continue; + } + const gitDir = path.resolve(directory, target); + if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) { + linkedWorktrees.push({ directory, gitDir }); + } else repositories.push({ directory, gitDir, linkedWorktree: false }); } - const configText = yield* fileSystem - .readFileString(path.join(gitDir, "config")) - .pipe(Effect.orElseSucceed(() => "")); - // Bare repositories have no main checkout, so offer their linked worktrees independently. - if (linkedWorktree && !isBareGitConfig(configText)) return { _tag: "Worktree" } as const; - const originUrl = parseOriginUrlFromGitConfig(configText); - return { - _tag: "Repository", - git: { - remoteKey: - originUrl === null ? null : sourceControlDefinitions.repositoryKeyFromRemote(originUrl), - repository: repositoryPathFromRemoteUrl(originUrl), - }, - } as const; + + const commonDirTexts = yield* readGitFiles( + linkedWorktrees.map(({ gitDir }) => path.join(gitDir, "commondir")), + ); + for (const { directory, gitDir } of linkedWorktrees) { + const commonDir = (commonDirTexts.get(path.join(gitDir, "commondir")) ?? "").trim(); + if (commonDir.length === 0) identities.set(directory, { _tag: "Worktree" }); + else + repositories.push({ + directory, + gitDir: path.resolve(gitDir, commonDir), + linkedWorktree: true, + }); + } + + const configTexts = yield* readGitFiles( + repositories.map(({ gitDir }) => path.join(gitDir, "config")), + ); + for (const { directory, gitDir, linkedWorktree } of repositories) { + const configText = configTexts.get(path.join(gitDir, "config")) ?? ""; + // Bare repositories have no main checkout, so offer their linked worktrees independently. + if (linkedWorktree && !isBareGitConfig(configText)) { + identities.set(directory, { _tag: "Worktree" }); + continue; + } + const originUrl = parseOriginUrlFromGitConfig(configText); + identities.set(directory, { + _tag: "Repository", + git: { + remoteKey: + originUrl === null ? null : sourceControlDefinitions.repositoryKeyFromRemote(originUrl), + repository: repositoryPathFromRemoteUrl(originUrl), + }, + }); + } + return identities; }); const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( @@ -908,7 +1001,6 @@ export const make = Effect.gen(function* () { const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( filePath: string, expected: ReturnType, - recordLimit: number, source: AgentSessionSource, ) { if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; @@ -942,7 +1034,7 @@ export const make = Effect.gen(function* () { const finishRecord = () => { reader.write(decoder.decode()); recordCount += 1; - if (recordCount > recordLimit) return false; + if (recordCount > MAX_IMPORT_RECORDS) return false; const value = reader.finish(); const decoded = decodeTranscriptValue(value); if (Option.isNone(decoded) && source === "pi") { @@ -1002,7 +1094,7 @@ export const make = Effect.gen(function* () { if (recordStarted && !(yield* Effect.try(finishRecord))) return null; return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) - ? { records, recordCount, cwd, claudeFields } + ? { records, cwd, claudeFields } : null; }), ), @@ -1385,6 +1477,30 @@ 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) || isExcludedProjectPath(resolved)) continue; + const stats = yield* statOption(resolved); + if (Option.isNone(stats) || stats.value.type !== "Directory") { + directories.set(resolved, Option.none()); + continue; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + // Symlinks can disguise excluded worktree paths; re-check their targets. + directories.set(resolved, isExcludedProjectPath(realPath) ? Option.none() : stats); + } + const directoryGitIdentities = yield* readGitIdentities( + Array.from(directories).flatMap(([directory, stats]) => + Option.isSome(stats) ? [directory] : [], + ), + ); + const directoryKeys = new Map(); const gitIdentities = new Map(); @@ -1392,28 +1508,16 @@ export const make = Effect.gen(function* () { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); - if (isExcludedProjectPath(resolved)) continue; + const stats = directories.get(resolved); + if (stats === undefined || Option.isNone(stats)) continue; let key = directoryKeys.get(resolved); if (key === undefined) { - const stats = yield* statOption(resolved); - if (Option.isNone(stats) || stats.value.type !== "Directory") { - directoryKeys.set(resolved, ""); - continue; - } - const realPath = yield* fileSystem - .realPath(resolved) - .pipe(Effect.orElseSucceed(() => resolved)); - // Symlinks can disguise excluded worktree paths; re-check their targets. - if (isExcludedProjectPath(realPath)) { + const gitIdentity = directoryGitIdentities.get(resolved); + if (gitIdentity === undefined || gitIdentity._tag === "Worktree") { key = ""; } else { - const gitIdentity = yield* readGitIdentity(resolved); - if (gitIdentity._tag === "Worktree") { - key = ""; - } else { - key = yield* directoryIdentity(resolved, stats.value); - gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); - } + key = yield* directoryIdentity(resolved, stats.value); + gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); } directoryKeys.set(resolved, key); } @@ -1544,17 +1648,13 @@ export const make = Effect.gen(function* () { const codexThreadNamesByHome = new Map>(); let bytesRemaining = MAX_IMPORT_BYTES; let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; - let recordsRemaining = MAX_IMPORT_RECORDS; return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( Stream.mapEffect(({ candidate, transcript }) => Effect.gen(function* () { const completed = completedByFile.get( `${candidate.providerInstanceId}\0${transcript.filePath}`, ); - if ( - completed === undefined && - (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) - ) { + if (completed === undefined && (transcriptsRemaining === 0 || bytesRemaining === 0)) { return Option.some({ _tag: "Skipped" }); } const stats = yield* statOption(transcript.filePath); @@ -1577,7 +1677,6 @@ export const make = Effect.gen(function* () { } if ( transcriptsRemaining === 0 || - recordsRemaining === 0 || identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || identity.size > bytesRemaining ) { @@ -1585,16 +1684,10 @@ export const make = Effect.gen(function* () { } transcriptsRemaining -= 1; bytesRemaining -= identity.size; - const snapshot = yield* readTranscript( - transcript.filePath, - identity, - recordsRemaining, - candidate.source, - ); + const snapshot = yield* readTranscript(transcript.filePath, identity, candidate.source); if (snapshot === null) { return Option.some({ _tag: "Skipped" }); } - recordsRemaining -= snapshot.recordCount; if (Option.isNone(snapshot.cwd)) { return Option.some({ _tag: "Skipped" }); @@ -1661,4 +1754,6 @@ export const make = Effect.gen(function* () { return AgentSessionScanner.of({ scan, recentThreads }); }); -export const layer = Layer.effect(AgentSessionScanner, make); +export const layer = Layer.effect(AgentSessionScanner, make).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 86dbcc5beb618..b5e3ef9a13b29 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, ProjectId, ThreadId, VcsProcessSpawnError } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -76,7 +77,7 @@ const layer = (baseDir: string, options?: HarnessOptions) => Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const withScratch = ( @@ -96,7 +97,7 @@ const withScratch = ( const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-managed-folders-" }); return yield* body({ baseDir }).pipe(Effect.provide(layer(baseDir, options))); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const git = (cwd: string, args: ReadonlyArray) => ProcessRunner.ProcessRunner.pipe( @@ -124,7 +125,7 @@ it.effect("offers nothing when the data dir sits inside a Git checkout", () => const failure = yield* Effect.flip(scratch.ensureScratchProject); assert.equal(failure._tag, "ScratchUnavailableError"); }).pipe(Effect.provide(layer(baseDir))); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("offers a Scratch folder when Git is not installed", () => diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index df6cb6f6b507e..c187424870c61 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -8,7 +8,7 @@ import { import { newProjectFolderName } from "@t3tools/shared/path"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -208,7 +208,7 @@ const make = Effect.gen(function* () { const gitWorkflow = yield* GitWorkflow.GitWorkflowService; const git = yield* GitVcsDriver.GitVcsDriver; const projects = yield* ProjectService.ProjectService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists. const claimFreeFolder = Effect.fnUntraced(function* ( @@ -261,8 +261,8 @@ const make = Effect.gen(function* () { ), ); yield* makeScratchFolder(workspaceRoot); - const id = yield* crypto - .randomUUIDv4() + const id = yield* random + .uuidV4() .pipe(Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause }))); const bootstrapped = yield* projects .bootstrap({ @@ -333,7 +333,7 @@ const make = Effect.gen(function* () { ? Effect.succeedSome(folderFor(id.slice(-8))) : attempt === 2 ? Effect.succeedSome(fullFolder) - : crypto.randomUUIDv4().pipe( + : random.uuidV4().pipe( Effect.map((uuid) => Option.some(`${fullFolder}-${uuid.slice(0, 8)}`)), Effect.mapError((cause) => new ScratchFolderError({ folder: fullFolder, cause })), ), @@ -427,8 +427,8 @@ const make = Effect.gen(function* () { scaffoldRepository(workspaceRoot, input.name).pipe( Effect.mapError((cause) => new NamedProjectFolderError({ folder: workspaceRoot, cause })), ), - crypto - .randomUUIDv4() + random + .uuidV4() .pipe(Effect.mapError((cause) => new NamedProjectCreateError({ workspaceRoot, cause }))), ]).pipe(Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : removeFolder))); const [commitError, id] = prepared; diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index f67ec6e53bb4d..538beed6f1f06 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -210,63 +210,67 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( { concurrency: "unbounded", discard: true }, ); - const requestLane = Effect.fn("ProjectEnrichmentService.requestLane")(function* ( + // Every project read lands here; only an actual enqueue gets a span, the resolve workers trace the work. + const requestLane = Effect.fnUntraced(function* ( lane: EnrichmentWorkLane, workspaceRoot: string, field: "repositoryIdentity" | "faviconPath", ) { if (!(yield* reservePending(lane, workspaceRoot))) return; - if (!(yield* Queue.offer(lane.queue, workspaceRoot))) { - yield* removePending(lane, workspaceRoot); - yield* Effect.logWarning("Project metadata enrichment queue is full", { - workspaceRoot, - field, - }); - } + yield* Effect.gen(function* () { + if (!(yield* Queue.offer(lane.queue, workspaceRoot))) { + yield* removePending(lane, workspaceRoot); + yield* Effect.logWarning("Project metadata enrichment queue is full", { + workspaceRoot, + field, + }); + } + }).pipe(Effect.withSpan("ProjectEnrichmentService.requestLane")); }); - const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fn( - "ProjectEnrichmentService.peek", - )(function* (workspaceRoot) { - const [repositoryIdentity, faviconPath] = yield* Effect.all( - [ - Cache.getSuccess(repositoryIdentityCache, workspaceRoot), - Cache.getSuccess(faviconCache, workspaceRoot), - ] as const, - { concurrency: "unbounded" }, - ); - return { - repositoryIdentity: availableValue(repositoryIdentity), - faviconPath: availableValue(faviconPath), - repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), - }; - }); + // peek/request/getAvailable run per project on every shell read, inside the caller's span; own spans were ~0ms noise. + const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fnUntraced( + function* (workspaceRoot) { + const [repositoryIdentity, faviconPath] = yield* Effect.all( + [ + Cache.getSuccess(repositoryIdentityCache, workspaceRoot), + Cache.getSuccess(faviconCache, workspaceRoot), + ] as const, + { concurrency: "unbounded" }, + ); + return { + repositoryIdentity: availableValue(repositoryIdentity), + faviconPath: availableValue(faviconPath), + repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), + }; + }, + ); - const request: ProjectEnrichmentService["Service"]["request"] = Effect.fn( - "ProjectEnrichmentService.request", - )(function* (workspaceRoot) { - const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( - [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], - { concurrency: "unbounded" }, - ); - yield* Effect.all( - [ - hasRepositoryIdentity - ? Effect.void - : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), - hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), - ], - { concurrency: "unbounded", discard: true }, - ); - }); + const request: ProjectEnrichmentService["Service"]["request"] = Effect.fnUntraced( + function* (workspaceRoot) { + const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( + [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], + { concurrency: "unbounded" }, + ); + yield* Effect.all( + [ + hasRepositoryIdentity + ? Effect.void + : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), + hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), + ], + { concurrency: "unbounded", discard: true }, + ); + }, + ); - const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fn( - "ProjectEnrichmentService.getAvailable", - )(function* (workspaceRoot) { - const available = yield* peek(workspaceRoot); - yield* request(workspaceRoot); - return available; - }); + const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fnUntraced( + function* (workspaceRoot) { + const available = yield* peek(workspaceRoot); + yield* request(workspaceRoot); + return available; + }, + ); const readRepositoryIdentity: ProjectEnrichmentService["Service"]["readRepositoryIdentity"] = Effect.fn("ProjectEnrichmentService.readRepositoryIdentity")(function* (workspaceRoot) { diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index e85f38bedb6fc..3af2ccf5c3999 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -1,7 +1,9 @@ +import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as Queue from "effect/Queue"; import * as Tracer from "effect/Tracer"; import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -102,7 +104,7 @@ const layerTestFor = ( Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerEnrichmentCompletions), ); @@ -138,7 +140,7 @@ it.effect("resolves only the requested active project page in creation order", ( const service = yield* ProjectService.ProjectService; const enrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; const changes = yield* enrichment.subscribeChanges; - const rows = Array.from({ length: 6 }, (_, index) => ({ + const rows = Arr.makeBy(6, (index) => ({ project_id: `page:${index}`, title: `Project ${index}`, workspace_root: `/work/page-${index}`, @@ -204,7 +206,7 @@ const layerProjectServiceDependencies = Layer.mergeAll( Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* ( @@ -537,6 +539,8 @@ it.layer(layerTest)("ProjectService", (it) => { Effect.gen(function* () { const service = yield* ProjectService.ProjectService; const sql = yield* SqlClient.SqlClient; + const enrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; + const changes = yield* enrichment.subscribeChanges; const projectId = ProjectId.make("project:delete-retry"); yield* service.create({ commandId: CommandId.make("command:delete-retry:create"), @@ -544,6 +548,12 @@ it.layer(layerTest)("ProjectService", (it) => { title: "Delete retry", workspaceRoot: "/work/delete-retry", }); + // Wait out the identity + favicon lanes so an in-flight cache write cannot + // land between the delete and its replay and change the replayed result. + for (;;) { + const change = yield* PubSub.take(changes); + if (change.workspaceRoot === "/work/delete-retry") break; + } const input = { commandId: CommandId.make("command:delete-retry:delete"), projectId }; const deleted = yield* service.delete(input); assert.deepEqual(yield* service.delete(input), deleted); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 45f0e815feaff..9454bbbe32954 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -9,7 +9,7 @@ import { import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -205,7 +205,7 @@ export const make = Effect.gen(function* () { const threads = yield* ProjectionStore.ProjectionStoreV2; const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const serviceScope = yield* Effect.scope; const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( @@ -436,7 +436,7 @@ export const make = Effect.gen(function* () { // Earlier settle shells may still run foreground programs; do not type resumed settles into them. const terminalId = trigger === "settle" - ? `settle-${script.id}-${(yield* crypto.randomUUIDv4().pipe(Effect.orDie)).slice(0, 8)}` + ? `settle-${script.id}-${(yield* random.uuidV4()).slice(0, 8)}` : `setup-${script.id}`; const cwd = input.worktreePath; const env = { @@ -448,9 +448,7 @@ export const make = Effect.gen(function* () { COLORTERM: "", }; const observe = input.observeCompletion; - const completionToken = observe - ? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).replaceAll("-", "") - : null; + const completionToken = observe ? (yield* random.uuidV4()).replaceAll("-", "") : null; const echoedWrapperLines: string[] = []; // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = diff --git a/apps/server/src/project/ScratchThreadPromotion.ts b/apps/server/src/project/ScratchThreadPromotion.ts index 671a480a95832..f8d28a9fe007b 100644 --- a/apps/server/src/project/ScratchThreadPromotion.ts +++ b/apps/server/src/project/ScratchThreadPromotion.ts @@ -5,7 +5,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -48,7 +48,7 @@ const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const threads = yield* ThreadManagementService.ThreadManagementService; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const path = yield* Path.Path; const fail = (reason: string) => (cause: unknown) => @@ -128,9 +128,7 @@ const make = Effect.gen(function* () { ); }); - const randomId = crypto - .randomUUIDv4() - .pipe(Effect.mapError(fail("Could not create an id for the project."))); + const randomId = random.uuidV4(); // bootstrap returns the project that already owns the folder, so retries reuse it. const createProject = Effect.fn("ScratchThreadPromotion.createProject")(function* ( diff --git a/apps/server/src/project/conversationSummaries/ConversationSummaries.test.ts b/apps/server/src/project/conversationSummaries/ConversationSummaries.test.ts index 0bd11c30c6a21..79bd6e20be4a6 100644 --- a/apps/server/src/project/conversationSummaries/ConversationSummaries.test.ts +++ b/apps/server/src/project/conversationSummaries/ConversationSummaries.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, @@ -26,7 +27,7 @@ import { } from "@t3tools/contracts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { historyCost } from "@t3tools/provider-core/server/handoffBudget"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -124,7 +125,7 @@ const makeLayer = (generate: Generate, settings: Parameters MessageId.make(`message-${index}`); @@ -266,9 +267,9 @@ const persistSummary = Effect.fn("test.persistSummary")(function* ( stale = false, ) { const sql = yield* SqlClient.SqlClient; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const sources = yield* Effect.forEach(sourceIndexes, (sourceIndex) => - crypto.digest("SHA-256", new TextEncoder().encode(rawText(sourceIndex))).pipe( + crypto.digest(Digest.sha256, new TextEncoder().encode(rawText(sourceIndex))).pipe( Effect.map((digest) => ({ kind: "message" as const, threadId, @@ -896,7 +897,7 @@ it.effect("returns canonical source text through MCP expand only to callers that ConversationSummariesToolkit, SummaryHandlers.layer, ).pipe( - Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (id) => diff --git a/apps/server/src/project/conversationSummaries/ConversationSummaries.ts b/apps/server/src/project/conversationSummaries/ConversationSummaries.ts index 5bb6ce84f73b2..300092ce70e02 100644 --- a/apps/server/src/project/conversationSummaries/ConversationSummaries.ts +++ b/apps/server/src/project/conversationSummaries/ConversationSummaries.ts @@ -20,7 +20,7 @@ import { } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -142,14 +142,14 @@ function planLeafChunks(input: { const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const projects = yield* ProjectStore.ProjectStoreV2; const projections = yield* ProjectionStore.ProjectionStoreV2; const knowledge = yield* ProjectKnowledge.ProjectKnowledge; const serverSettings = yield* ServerSettings.ServerSettingsService; const textGeneration = yield* TextGeneration.TextGeneration; const digest = (text: string) => - crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.map(Hex.encode)); + crypto.digest(Digest.sha256, new TextEncoder().encode(text)).pipe(Effect.map(Hex.encode)); const storageError = (projectId: ProjectId) => (cause: unknown) => isConversationSummaryError(cause) diff --git a/apps/server/src/project/knowledge/AssistedLearning.test.ts b/apps/server/src/project/knowledge/AssistedLearning.test.ts index 78bdd6fef81a6..6cd406a681452 100644 --- a/apps/server/src/project/knowledge/AssistedLearning.test.ts +++ b/apps/server/src/project/knowledge/AssistedLearning.test.ts @@ -13,8 +13,9 @@ import { ThreadId, type OrchestrationV2DomainEvent, } from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -62,7 +63,7 @@ const makeLayer = (generate: Generate, settings: Parameters ); const sha256 = (text: string) => - Effect.flatMap(Crypto.Crypto, (crypto) => - crypto.digest("SHA-256", new TextEncoder().encode(text)), + Effect.flatMap(Digest.Digest, (crypto) => + crypto.digest(Digest.sha256, new TextEncoder().encode(text)), ).pipe(Effect.map(Hex.encode)); const snakeCase = { diff --git a/apps/server/src/project/knowledge/AssistedLearning.ts b/apps/server/src/project/knowledge/AssistedLearning.ts index 1922729623148..9175ec85c8096 100644 --- a/apps/server/src/project/knowledge/AssistedLearning.ts +++ b/apps/server/src/project/knowledge/AssistedLearning.ts @@ -7,7 +7,7 @@ import { type ProjectId, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -48,7 +48,7 @@ const decodeCandidate = Schema.decodeUnknownOption(Candidate); const isMutation = Schema.is(ProjectKnowledgeMutation); const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const sql = yield* SqlClient.SqlClient; const knowledge = yield* ProjectKnowledge.ProjectKnowledge; const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -56,7 +56,7 @@ const make = Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; const textGeneration = yield* TextGeneration.TextGeneration; const digest = (text: string) => - crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.map(Hex.encode)); + crypto.digest(Digest.sha256, new TextEncoder().encode(text)).pipe(Effect.map(Hex.encode)); const extractRun: AssistedLearningRunnerShape["extractRun"] = Effect.fn( "AssistedLearning.extractRun", diff --git a/apps/server/src/project/knowledge/CodeIntelligence.test.ts b/apps/server/src/project/knowledge/CodeIntelligence.test.ts index ef6adc25a8705..49a184d9f9e14 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.test.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.test.ts @@ -311,7 +311,7 @@ it.effect( Layer.provide(provided), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(Layer.mock(FileHistory.FileHistory)({})), - Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCryptoEffect.layer), Layer.provide(McpToolAccessTestkit.liveThreadsLayer), ); diff --git a/apps/server/src/project/knowledge/ProjectKnowledge.test.ts b/apps/server/src/project/knowledge/ProjectKnowledge.test.ts index 6fcc601f003ff..b1f2d4b0f4fe8 100644 --- a/apps/server/src/project/knowledge/ProjectKnowledge.test.ts +++ b/apps/server/src/project/knowledge/ProjectKnowledge.test.ts @@ -22,6 +22,7 @@ import { type OrchestrationV2DomainEvent, type ProjectKnowledgeMutation, } from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -79,7 +80,7 @@ const knowledgeTestLayer = (scoring: typeof HolographicMemory.layer = Holographi getEnvironmentId: Effect.succeed(environmentId), }), ), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerTest = knowledgeTestLayer(); @@ -736,7 +737,7 @@ it.effect("reopens durable project facts without another content authority", () getEnvironmentId: Effect.succeed(environmentId), }), ), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const projectId = ProjectId.make("durable-project"); const saved = yield* Effect.gen(function* () { @@ -749,7 +750,7 @@ it.effect("reopens durable project facts without another content authority", () return yield* knowledge.list({ projectId }); }).pipe(Effect.provide(layerFile)); assert.deepEqual(reopened.facts, [saved]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -2588,7 +2589,7 @@ it.effect( getEnvironmentId: Effect.succeed(environmentId), }), ), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const projectId = ProjectId.make("restart-delivery-project"); const threadId = ThreadId.make("restart-delivery-thread"); @@ -2657,5 +2658,5 @@ it.effect( assert.equal(yield* knowledge.nativeDeliveryEstimate(receipt), 777); }).pipe(Effect.provide(reopened())), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/server/src/project/knowledge/ProjectKnowledge.ts b/apps/server/src/project/knowledge/ProjectKnowledge.ts index 82280138c857b..d71e7f7d16ef0 100644 --- a/apps/server/src/project/knowledge/ProjectKnowledge.ts +++ b/apps/server/src/project/knowledge/ProjectKnowledge.ts @@ -33,7 +33,7 @@ import { projectComposerContextForProvider, projectKnowledgeContextRecord, } from "@t3tools/shared/composerContextReferences"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Hex from "effect/encoding/Hex"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -207,9 +207,9 @@ const storageError = (projectId: ProjectId) => (cause: unknown) => const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const digest = Effect.fn("ProjectKnowledge.digest")((text: string) => - crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.map(Hex.encode)), + crypto.digest(Digest.sha256, new TextEncoder().encode(text)).pipe(Effect.map(Hex.encode)), ); const store = yield* makeStore; const holographic = yield* HolographicMemory.HolographicMemory; diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index e6835159f9e7f..1f45e78320e5a 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -2,7 +2,9 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { ProviderInstanceId } from "@t3tools/contracts"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { describe, expect, it } from "@effect/vitest"; @@ -370,7 +372,7 @@ describe("ACP Registry probe", () => { yield* logoutAcpRegistry(input); }).pipe( Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped, ); }); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index b6a542d5a497e..e11011591da16 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; @@ -87,7 +88,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(ThreadManagementService)({}), Layer.mock(ThreadSearch)({}), Layer.mock(VcsStatusBroadcaster)({}), -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const utf8Bytes = (text: string) => Buffer.byteLength(text, "utf8"); @@ -143,7 +144,7 @@ it.effect("agent context matches the committed budget", () => }).pipe( Effect.provide( McpHttpServer.layerTools.pipe( - Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(toolHandlerServices), ), ), diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index af156a61663f4..4664c299e8a5c 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -1,4 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -145,7 +147,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( }; }); -it.layer(NodeServices.layer)("AntigravityAuth", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AntigravityAuth", (it) => { it.effect("accepts a delayed duplicate after callback completion starts", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index 67f25681a1a4b..7f19bd3a9b077 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -137,8 +137,8 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < Runtime extends AntigravityAuthRuntime, >( options: AntigravityAuthOptions, -): Effect.fn.Return { - const crypto = yield* Crypto.Crypto; +): Effect.fn.Return { + const random = yield* SecureRandom.SecureRandom; const instanceScope = yield* Scope.Scope; const usesBrowser = options.usesBrowser ?? true; const lock = yield* Semaphore.make(1); @@ -377,8 +377,8 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < if (operation !== "idle") { return yield* setupError("start", "Antigravity setup is already in progress."); } - const flowId = yield* crypto - .randomUUIDv4() + const flowId = yield* random + .uuidV4() .pipe( Effect.mapError(() => setupError("start", "Could not start Google sign-in. Try again."), diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index 363eb4b78858a..fb142d41decdd 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -1,7 +1,9 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -58,11 +60,11 @@ const releaseAsset = Effect.fn("test.antigravityReleaseAsset")(function* ( archive: Uint8Array = completeArchive, platform: NodeJS.Platform = hostPlatform, ) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; return { version: "fixture-new", url: "https://dl.google.com/antigravity-test.zip", - sha256: Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie)), + sha256: Hex.encode(yield* digest.digest(Digest.sha256, archive).pipe(Effect.orDie)), archiveBytes: archive.byteLength, executable: { name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par", @@ -233,7 +235,7 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease") expect((yield* installation.state).installedVersion).toBe(previousVersion); }); -it.layer(NodeServices.layer)("Antigravity installation", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("Antigravity installation", (it) => { it.effect("reports missing Node before downloading the standalone provider runtime", () => Effect.gen(function* () { const { installation, requests, validations } = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 84df7d6201901..b70e5d6860000 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -6,7 +6,7 @@ import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/s import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -151,7 +151,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const http = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serviceScope = yield* Effect.scope; @@ -419,7 +419,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.mapError((cause) => { if (isInstallationError(cause)) return cause; const detail = @@ -715,7 +715,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, ); } - const operationId = yield* crypto.randomUUIDv4(); + const operationId = yield* random.uuidV4(); const next: ProviderInstallState = { driver: DRIVER, operationId, diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 349a44a7005e1..44f8842fd9591 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { @@ -24,6 +25,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import type { ServerProvider } from "@t3tools/contracts"; import { buildAntigravityModelsFromSession, makeAntigravityProvider, @@ -120,7 +122,7 @@ const layerTest = ProviderHostLive.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-provider-test-" }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ); @@ -167,6 +169,11 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( Effect.andThen(initialUpdate), Effect.asVoid, ); + // Subscribe before the trigger; yield the returned pull to await the publish. + const nextPublish = (predicate: (snapshot: ServerProvider) => boolean) => + Stream.toPull(provider.snapshot.streamChanges.pipe(Stream.filter(predicate))).pipe( + Effect.map((pull) => pull.pipe(Effect.map((snapshots) => snapshots[0]))), + ); return { provider, probe, @@ -176,6 +183,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( initialProbe, initialUpdate, initialize, + nextPublish, }; }); @@ -687,15 +695,29 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { }; const usage = yield* Ref.make(limits); const harness = yield* makeHarness({ probeUsage: Ref.get(usage) }); + const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); yield* harness.initialize; - expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual(limits); - yield* Ref.set(harness.probe, Effect.succeed(initializeResult)); + expect((yield* published)?.usageLimits).toEqual(limits); + // The second health check is the one the failed background read triggers. + const checks = yield* Ref.make(0); + const failedReadApplied = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.tap((count) => + count === 2 ? Deferred.succeed(failedReadApplied, undefined) : Effect.void, + ), + Effect.as(initializeResult), + ), + ); yield* Ref.set(usage, { checkedAt: "2026-10-09T12:01:00.000Z", windows: [], unavailable: { reason: "probeFailed" }, }); - expect((yield* harness.provider.snapshot.refresh).usageLimits).toEqual(limits); + yield* harness.provider.snapshot.refresh; + yield* Deferred.await(failedReadApplied); + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual(limits); yield* harness.provider.onSignedOut; expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); yield* Ref.set(usage, { @@ -703,7 +725,11 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { windows: [], unavailable: { reason: "unsupported" }, }); - expect((yield* harness.provider.snapshot.refresh).usageLimits?.windows).toEqual([]); + const unsupported = yield* harness.nextPublish( + (snapshot) => snapshot.usageLimits?.unavailable?.reason === "unsupported", + ); + yield* harness.provider.snapshot.refresh; + expect((yield* unsupported)?.usageLimits?.windows).toEqual([]); }), ), ); @@ -737,4 +763,74 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { }), ), ); + + it.effect("reports ready and starts sessions while one shared quota read is pending", () => + Effect.scoped( + Effect.gen(function* () { + const reads = yield* Ref.make(0); + const readStarted = yield* Deferred.make(); + const readResult = yield* Deferred.make(); + const harness = yield* makeHarness({ + probeUsage: Ref.update(reads, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(readStarted, undefined)), + Effect.andThen(Deferred.await(readResult)), + ), + }); + const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); + yield* harness.initialize; + yield* Deferred.await(readStarted); + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onSessionStarted(started, "/other"); + const ready = yield* harness.provider.snapshot.getSnapshot; + expect(ready).toMatchObject({ status: "ready", auth: { status: "authenticated" } }); + expect(ready.usageLimits).toBeUndefined(); + expect(yield* Ref.get(reads)).toBe(1); + const limits = { + checkedAt: "2026-10-09T12:00:00.000Z", + windows: [ + { id: "gemini-5h", kind: "session", label: "Gemini · 5-hour", usedPercent: 10 }, + ], + } satisfies ServerProviderUsageLimits; + yield* Deferred.succeed(readResult, limits); + expect((yield* published)?.usageLimits).toEqual(limits); + }), + ), + ); + + it.effect("a quota read for the previous account is dropped after a new sign-in", () => + Effect.scoped( + Effect.gen(function* () { + const oldReadStarted = yield* Deferred.make(); + const newLimits = { + checkedAt: "2026-10-09T12:01:00.000Z", + windows: [{ id: "3p-weekly", kind: "weekly", label: "Weekly", usedPercent: 40 }], + } satisfies ServerProviderUsageLimits; + const usage = yield* Ref.make>( + Deferred.succeed(oldReadStarted, undefined).pipe(Effect.andThen(Effect.never)), + ); + const harness = yield* makeHarness({ probeUsage: Ref.get(usage).pipe(Effect.flatten) }); + const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); + yield* harness.initialize; + yield* Deferred.await(oldReadStarted); + yield* Ref.set(usage, Effect.succeed(newLimits)); + yield* harness.provider.onSignedIn; + yield* harness.provider.onSessionStarted(started); + expect((yield* published)?.usageLimits).toEqual(newLimits); + // Switching again must not republish the previous account's limits, even if the read fails. + const failed = { + checkedAt: "2026-10-09T12:02:00.000Z", + windows: [], + unavailable: { reason: "probeFailed" }, + } satisfies ServerProviderUsageLimits; + yield* Ref.set(usage, Effect.succeed(failed)); + const republished = yield* harness.nextPublish( + (snapshot) => snapshot.usageLimits?.unavailable?.reason === "probeFailed", + ); + yield* harness.provider.onSignedIn; + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); + yield* harness.provider.onSessionStarted(started); + expect((yield* republished)?.usageLimits).toEqual(failed); + }), + ), + ); }); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 6dcde50c5329c..ff8805f43b371 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -9,8 +9,11 @@ import { type ServerProviderUsageLimits, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +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 FiberHandle from "effect/FiberHandle"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; @@ -41,6 +44,10 @@ const HEALTH_CHECK_TIMEOUT = "90 seconds"; const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; const AUTH_UNCHECKED_MESSAGE = "Antigravity is installed. Google account access is not checked yet."; +// Set while a finished quota read republishes, so that health check does not read again. +const PublishingUsage = Context.Reference("t3/provider/AntigravityPublishingUsage", { + defaultValue: () => false, +}); type SessionSetupResult = Pick< AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"], @@ -215,6 +222,47 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func Effect.flatMap((state) => options.stampIdentity(state.draft)), ); + // The quota read can take 30 seconds, so it never blocks readiness or a session start. + // One read runs at a time; sign-in and sign-out interrupt it so a stale account never lands. + const usageRead = yield* FiberHandle.make(); + const republish = yield* Deferred.make>(); + const scope = yield* Effect.scope; + const refreshUsage = Effect.fn("AntigravityProvider.refreshUsage")(function* () { + if (!options.probeUsage || (yield* PublishingUsage)) return; + yield* FiberHandle.run( + usageRead, + options.probeUsage.pipe( + Effect.flatMap((probed) => + SubscriptionRef.update(metadata, (state) => ({ + ...state, + draft: { + ...state.draft, + usageLimits: resolveUsageLimitsAfterProbe({ + published: state.draft.usageLimits, + probed, + }), + }, + })), + ), + // Enrichment cannot change published limits; a health check publishes them. + Effect.andThen( + Deferred.await(republish).pipe( + Effect.flatten, + Effect.provideService(PublishingUsage, true), + Effect.ignoreCause({ log: true }), + Effect.forkIn(scope), + ), + ), + ), + // Store the fiber before it runs, so a sign-out during the read can interrupt it. + { onlyIfMissing: true, startImmediately: false }, + ); + }); + const resetUsage = Effect.gen(function* () { + yield* FiberHandle.clear(usageRead); + if (options.clearUsage) yield* options.clearUsage; + }); + const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { if (!settings.enabled) return yield* getSnapshot; const before = yield* SubscriptionRef.get(metadata); @@ -239,8 +287,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; const supportsTextGeneration = initialized !== undefined ? yield* options.supportsTextGeneration : false; - const usageLimits = - initialized !== undefined && options.probeUsage ? yield* options.probeUsage : undefined; const updatedAt = DateTime.formatIso(yield* DateTime.now); const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { if (state.authRevision !== before.authRevision) return state; @@ -262,12 +308,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func version: version === "unknown" ? draft.version : version || draft.version, status: errorMessage ? "error" : authenticated ? "ready" : "warning", checkedAt: updatedAt, - usageLimits: missingInstallation - ? undefined - : resolveUsageLimitsAfterProbe({ - published: draft.usageLimits, - probed: usageLimits ?? draft.usageLimits, - }), + usageLimits: missingInstallation ? undefined : draft.usageLimits, ...(missingInstallation ? { models: [], @@ -287,6 +328,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }, } satisfies AntigravityProviderState; }); + if (initialized !== undefined) yield* refreshUsage(); return yield* options.stampIdentity(next.draft); }); @@ -310,6 +352,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func ), ), }); + yield* Deferred.succeed(republish, managed.refresh); const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( started: AcpSessionRuntime.AcpSessionRuntimeStartResult, @@ -319,10 +362,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func observeThinkingLevels(started.sessionSetupResult.configOptions); const before = yield* SubscriptionRef.get(metadata); const supportsTextGeneration = yield* options.supportsTextGeneration; - const usageLimits = - options.probeUsage && (!before.draft.usageLimits || before.draft.usageLimits.unavailable) - ? yield* options.probeUsage - : before.draft.usageLimits; const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update(metadata, (state) => { if ( @@ -347,10 +386,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func label: options.auth?.label ?? "Google account", }, checkedAt: updatedAt, - usageLimits: resolveUsageLimitsAfterProbe({ - published: draft.usageLimits, - probed: usageLimits ?? draft.usageLimits, - }), models: buildAntigravityModelsFromSession(started.sessionSetupResult, thinkingLevels), supportsTextGeneration, ...(cwd @@ -369,6 +404,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }, } satisfies AntigravityProviderState; }); + if (!before.draft.usageLimits || before.draft.usageLimits.unavailable) yield* refreshUsage(); }); const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( @@ -418,7 +454,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { - if (options.clearUsage) yield* options.clearUsage; + yield* resetUsage; if (!settings.enabled) return; const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update( @@ -481,6 +517,23 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func onSessionStarted, onConfigOptionsUpdated, onAvailableCommands, + // The new sign-in may be another account. Clear and republish its limits so a failed read + // cannot fall back to them; the session start then reads the new account's limits. + onSignedIn: resetUsage.pipe( + Effect.andThen( + SubscriptionRef.update(metadata, (state) => { + const { usageLimits: _previousAccount, ...draft } = state.draft; + return { ...state, draft }; + }), + ), + Effect.andThen( + Deferred.await(republish).pipe( + Effect.flatten, + Effect.provideService(PublishingUsage, true), + ), + ), + Effect.asVoid, + ), onSignedOut: clearAccountMetadata(), onAuthRequired: clearAccountMetadata(), snapshotForCwd, diff --git a/apps/server/src/provider/ClaudeCliAuth.test.ts b/apps/server/src/provider/ClaudeCliAuth.test.ts index 1003bb3525052..e76d57d3a1615 100644 --- a/apps/server/src/provider/ClaudeCliAuth.test.ts +++ b/apps/server/src/provider/ClaudeCliAuth.test.ts @@ -1,8 +1,10 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ClaudeSettings, ProviderInstanceId, type ProviderAuthState } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -148,7 +150,7 @@ it.effect("accepts a chunked native URL and verifies the private subscription", false, false, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["failure", "no-account", "api-key", "bad-status"])( @@ -167,7 +169,7 @@ it.effect.each(["failure", "no-account", "api-key", "bad-status"])( : "Claude could not verify a subscription account. Start sign-in again.", ); expect(yield* fixture.auth.withAccess!(Effect.succeed("available"))).toBe("available"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["no-url", "success"])("cancels the owned process %s", (mode) => @@ -179,7 +181,7 @@ it.effect.each(["no-url", "success"])("cancels the owned process %s", (mode) => expect((yield* fixture.auth.cancel("owner", state.flowId!)).phase).toBe("cancelled"); expect(yield* fixture.children[0]!.isRunning).toBe(false); expect(yield* fixture.auth.withAccess!(Effect.succeed("available"))).toBe("available"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("rejects malformed, stale, and other-client codes while keeping the login usable", () => @@ -207,7 +209,7 @@ it.effect("rejects malformed, stale, and other-client codes while keeping the lo } yield* fixture.auth.complete!("owner", { flowId: state.flowId!, callbackUrl: "fixture-code" }); expect((yield* fixture.phase("succeeded")).phase).toBe("succeeded"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("expires sign-in and closes its native process", () => @@ -218,7 +220,7 @@ it.effect("expires sign-in and closes its native process", () => yield* TestClock.adjust("6 minutes"); expect((yield* fixture.phase("failed")).message).toBe("Sign-in expired. Start again."); expect(yield* fixture.children[0]!.isRunning).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("signs out one private account and accepts repeated sign-out requests", () => @@ -255,7 +257,7 @@ it.effect("signs out one private account and accepts repeated sign-out requests" expect( yield* fixture.fs.readFileString(fixture.path.join(fixture.existing, "account.json")), ).toBe('{"email":"existing@example.test"}'); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("does not claim logout when the CLI still has credentials", () => @@ -265,7 +267,7 @@ it.effect("does not claim logout when the CLI still has credentials", () => expect((yield* fixture.auth.logout(Effect.void).pipe(Effect.flip)).detail).toBe( "Could not sign out of Claude. Try again.", ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("submits only one code to the owned login", () => @@ -282,5 +284,5 @@ it.effect("submits only one code to the owned login", () => ).toBe("A sign-in code was already submitted."); expect((yield* fixture.auth.cancel("owner", state.flowId!)).phase).toBe("cancelled"); expect(yield* fixture.children[0]!.isRunning).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 07678b6d594ff..bbe16bf91bfeb 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -45,7 +45,7 @@ import { } from "./Drivers/ClaudeHome.ts"; import { spawnClaudeProcess } from "./Drivers/ClaudeProcess.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { discoverClaudeSkills, readClaudeSettingsBaseUrl } from "./Drivers/ClaudeSkills.ts"; +import { discoverClaudeSkills, readClaudeSettingsEnv } from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; import { @@ -635,7 +635,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( // First-party account fields cannot verify custom endpoint credentials. const baseUrl = - (yield* readClaudeSettingsBaseUrl(claudeSettings, cwd, resolvedEnvironment)) ?? + (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ?? resolvedEnvironment.ANTHROPIC_BASE_URL; const endpoint = URL.parse(baseUrl?.trim() ?? ""); const customApiEndpoint = diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index 79eaaa60a11ce..2fa6a11bac1e5 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -49,12 +49,96 @@ function withEarlierSegmentUsage( return total; } +/** Per-model totals from the SDK's `modelUsage`, which grows for the life of one CLI process. */ +export type ClaudeModelLedger = ReadonlyMap< + string, + { readonly input: number; readonly output: number } +>; + +export interface ClaudeLedgerUsage { + readonly inputTokens: number; + readonly outputTokens: number; +} + +/** Unknown when absent or empty: a process that did model work never reports an empty ledger. */ +export function claudeModelLedger(modelUsage: unknown): ClaudeModelLedger | undefined { + if (typeof modelUsage !== "object" || modelUsage === null) return undefined; + if (Object.keys(modelUsage).length === 0) return undefined; + const ledger = new Map(); + for (const [model, entry] of Object.entries(modelUsage as Record)) { + const fields = (entry ?? {}) as Record; + const input = finiteNonNegativeInteger(fields.inputTokens); + const output = finiteNonNegativeInteger(fields.outputTokens); + if (input === undefined || output === undefined) return undefined; + const cacheRead = finiteNonNegativeInteger(fields.cacheReadInputTokens) ?? 0; + const cacheWrite = finiteNonNegativeInteger(fields.cacheCreationInputTokens) ?? 0; + ledger.set(model, { input: input + cacheRead + cacheWrite, output }); + } + return ledger; +} + +/** Ledger growth since `previous`. Unknown without a baseline or when a counter goes backwards. */ +export function claudeLedgerDelta( + previous: ClaudeModelLedger | undefined, + current: ClaudeModelLedger | undefined, +): ClaudeLedgerUsage | undefined { + if (previous === undefined || current === undefined) return undefined; + let inputTokens = 0; + let outputTokens = 0; + for (const [model, before] of previous) { + const after = current.get(model); + if (after === undefined || after.input < before.input || after.output < before.output) { + return undefined; + } + } + for (const [model, after] of current) { + const before = previous.get(model); + inputTokens += after.input - (before?.input ?? 0); + outputTokens += after.output - (before?.output ?? 0); + } + return { inputTokens, outputTokens }; +} + +export function addClaudeLedgerUsage( + left: ClaudeLedgerUsage | undefined, + right: ClaudeLedgerUsage | undefined, +): ClaudeLedgerUsage | undefined { + return left === undefined || right === undefined + ? undefined + : { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + }; +} + +export interface ClaudeTurnObservations { + // Models on the turn's root assistant messages. + readonly rootModels?: ReadonlySet; + // Ledger growth across every result this turn consumed, including steered segments. + readonly ledger?: ClaudeLedgerUsage | undefined; +} + export function normalizeClaudeTurnTokenUsage( - result: { readonly subtype: SDKResultMessage["subtype"]; readonly usage?: unknown } | undefined, + result: + | { + readonly subtype: SDKResultMessage["subtype"]; + readonly usage?: unknown; + readonly local_command?: unknown; + } + | undefined, hasSubagents: boolean, terminalStatus: RuntimeTurnState, earlierSegmentUsage: ReadonlyArray = [], + observed: ClaudeTurnObservations = {}, ): TurnTokenUsage { + const observedFields = { + ...(observed.rootModels !== undefined && observed.rootModels.size > 0 + ? { models: [...observed.rootModels] } + : {}), + ...(typeof result?.local_command === "string" && result.local_command.length > 0 + ? { localCommand: true } + : {}), + }; const finalUsage = result?.usage as Record | undefined; const usage = withEarlierSegmentUsage(finalUsage ?? {}, earlierSegmentUsage); const uncachedInputTokens = finiteNonNegativeInteger(usage.input_tokens); @@ -89,10 +173,25 @@ export function normalizeClaudeTurnTokenUsage( usageStatus: "unavailable", usageScope: "main_agent", hasSubagents, + ...observedFields, }; } + // The result omits subagent and compaction work that the process-wide ledger records. + const otherUsage = + observed.ledger !== undefined && + inputTokens !== undefined && + rawOutputTokens !== undefined && + observed.ledger.inputTokens >= inputTokens && + observed.ledger.outputTokens >= rawOutputTokens + ? { + inputTokens: observed.ledger.inputTokens - inputTokens, + outputTokens: observed.ledger.outputTokens - rawOutputTokens, + } + : undefined; const commonUsage = { + ...observedFields, + ...(otherUsage === undefined ? {} : { otherUsage }), usageScope: "main_agent", ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), ...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}), diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 3e521103d56cf..f629111264294 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -6,6 +6,7 @@ import * as ServerConfig from "../config.ts"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as NodeHttp from "node:http"; import * as NodeCrypto from "node:crypto"; +import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -403,7 +404,9 @@ const makeHarness = makeHarnessFor(instanceId); const provision = (effect: Effect.Effect) => effect.pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer)), + Effect.provide( + Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer, NodePlatformCrypto.layer), + ), ); it.effect( "changing account registers a new user-owned client and then reuses that registration", @@ -920,7 +923,9 @@ it.effect( assert.strictEqual(harness.exchanges.length, 2); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer, FetchHttpClient.layer), + ), ), ); @@ -957,7 +962,9 @@ it.effect( yield* harness.phase("succeeded"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer, FetchHttpClient.layer), + ), ), ); @@ -991,7 +998,12 @@ it.effect("keeps simultaneous remote accounts and environments independent", () yield* second.phase("succeeded"); assert.strictEqual(Option.getOrThrow(yield* first.auth.read).subject, "user-test"); assert.strictEqual(Option.getOrThrow(yield* second.auth.read).subject, "other-user"); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer, FetchHttpClient.layer), + ), + ), ); it.effect( @@ -1762,7 +1774,7 @@ const provideBroker = (effect: Effect.Effect) => Effect.scoped, Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-renewal-test-" }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer)), ), ), ); diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 3e51b66a0e5ee..457e85791b6db 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -14,7 +14,8 @@ import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { openAiUserId } from "../telemetry/Identify.ts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Hex from "effect/encoding/Hex"; import * as Exit from "effect/Exit"; import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; @@ -162,7 +163,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* options.instanceId, ); const secrets = yield* ServerSecretStore.ServerSecretStore; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; const environmentLocks = sessionLocks.get(secrets) ?? new Map(); sessionLocks.set(secrets, environmentLocks); const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1)); @@ -321,7 +323,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* }; }).pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), + Effect.provideService(Digest.Digest, digest), ); const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) { const saved = yield* readRegistrations; @@ -728,9 +731,9 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* // ID tokens survive renewal but change on sign-in. Bind active children to the // launch profile without putting an identity token or subject in their argv. const profileKey = (record: CodexChatGptCredentials) => - crypto + digest .digest( - "SHA-256", + Digest.sha256, new TextEncoder().encode( JSON.stringify([record.clientId, record.subject, record.idToken ?? null]), ), diff --git a/apps/server/src/provider/CodexCliAuth.test.ts b/apps/server/src/provider/CodexCliAuth.test.ts index ca3ca4875ba73..137d75a9e62a3 100644 --- a/apps/server/src/provider/CodexCliAuth.test.ts +++ b/apps/server/src/provider/CodexCliAuth.test.ts @@ -1,9 +1,11 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { CodexSettings, ProviderInstanceId, type ProviderAuthState } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -199,7 +201,7 @@ it.effect.each(["early", "after-response", "missing-id"])( "account/read", ]); expect(yield* fixture.children[0]!.isRunning).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["failure", "no-account"])("does not accept unverified sign-in %s", (mode) => @@ -218,7 +220,7 @@ it.effect.each(["failure", "no-account"])("does not accept unverified sign-in %s expect( yield* fixture.fs.readFileString(fixture.path.join(fixture.sharedHome, "auth.json")), ).toBe('{"global":"unchanged"}'); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["before-response", "waiting"])("cancels %s and closes its process", (mode) => @@ -243,7 +245,7 @@ it.effect.each(["before-response", "waiting"])("cancels %s and closes its proces : { method: "account/login/start", params: { type: "chatgpt" } }, ); expect(yield* fixture.fs.exists(fixture.path.join(fixture.homePath, "auth.json"))).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("keeps two accounts independent and logs out only the selected overlay", () => @@ -296,7 +298,7 @@ it.effect("keeps two accounts independent and logs out only the selected overlay yield* fixture.fs.readFileString(fixture.path.join(fixture.sharedHome, "auth.json")), ).toBe('{"global":"unchanged"}'); expect((yield* fixture.auth.logout(Effect.void)).phase).toBe("idle"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("fails logout when Codex still reports an account", () => @@ -311,7 +313,7 @@ it.effect("fails logout when Codex still reports an account", () => ), ).toBe("Could not sign out of Codex. Try again."); expect((yield* fixture.phase("failed")).message).toBe("Could not sign out. Try again."); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("releases the credential gate when Codex exits while waiting for sign-in", () => @@ -323,5 +325,5 @@ it.effect("releases the credential gate when Codex exits while waiting for sign- ); expect(yield* fixture.auth.withAccess!(Effect.succeed("available"))).toBe("available"); expect(yield* fixture.children[0]!.isRunning).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 87473e8da2f11..e8939f2368ada 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -65,7 +65,10 @@ describe("Codex orchestration context", () => { NodeAssert.ok(orchestration.includes("use your native tool when the child uses your provider")); NodeAssert.ok(orchestration.includes("`run_in_background:false`")); - NodeAssert.ok(orchestration.includes("A native type running another provider's model")); + NodeAssert.ok(orchestration.includes("A native type on another provider's model")); + NodeAssert.ok( + orchestration.includes("Background agent: `delegate_task`, never a background `Agent`."), + ); NodeAssert.ok( orchestration.includes("Every new delegate review or re-check is a new `delegate_task`"), ); diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index 8933ad58beaad..142677ccf38e9 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -1,8 +1,10 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import * as ModelManifest from "./ModelManifest.ts"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -64,8 +66,8 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( let releaseChecks = 0; let credentialInvalidations = 0; const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = []; - const crypto = yield* Crypto.Crypto; - const sha256 = Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie)); + const digest = yield* Digest.Digest; + const sha256 = Hex.encode(yield* digest.digest(Digest.sha256, archive).pipe(Effect.orDie)); let runtimeRenameFailures = input.runtimeRenameFailures ?? 0; const failedRenames = yield* Queue.unbounded(); const lockingFs: FileSystem.FileSystem = { @@ -202,7 +204,7 @@ it.effect.each(["0.156.0", "0.156.1", "0.156.2", "0.157.0"])( yield* h.installation.remove(); expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); expect((yield* h.installation.state).source).toBe("local"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([true, false])( "acquires only the managed runtime when requested with managed installation %s", @@ -231,7 +233,7 @@ it.effect.each([true, false])( "Codex is not installed in T3 Code. Install it to continue.", ); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("installs a managed runtime when requested despite a compatible host Codex", () => @@ -251,7 +253,7 @@ it.effect("installs a managed runtime when requested despite a compatible host C expect(installed.executablePath).toBe(`${h.baseDir}/tools/codex/0.156.1/bin/codex`); expect(h.downloads()).toBe(1); expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -275,7 +277,7 @@ it.effect.each([ expect(installed.executablePath).toBe(executable.executablePath); expect(h.downloads()).toBe(1); expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const compileNative = Effect.fn("test.compileNative")(function* ( @@ -305,7 +307,7 @@ it.effect("rechecks compatibility after the local executable is replaced", () => expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); expect((yield* h.installation.resolve()).source).toBe("managed"); expect(h.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("rechecks a cached local executable when the shared manifest policy changes", () => @@ -337,7 +339,7 @@ it.effect("rechecks a cached local executable when the shared manifest policy ch "--version", "app-server --help", ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("uses bundled Codex compatibility when the remote manifest omits its policy", () => @@ -351,7 +353,7 @@ it.effect("uses bundled Codex compatibility when the remote manifest omits its p }); expect((yield* h.installation.start()).source).toBe("local"); expect(h.downloads()).toBe(0); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -390,7 +392,7 @@ int main(int argc, char **argv) { expect(yield* h.installation.start()).toMatchObject({ source: "local", phase: "succeeded" }); expect((yield* h.installation.resolve()).executablePath).toBe(h.localBinaryPath); expect(h.downloads()).toBe(0); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -410,7 +412,7 @@ it.effect( ); const restarted = yield* makeHarness({ baseDir }); expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("requires an update before running an older activated managed installation", () => @@ -440,7 +442,7 @@ it.effect("requires an update before running an older activated managed installa expect((yield* terminalState(restarted.installation)).phase).toBe("succeeded"); expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); expect(restarted.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("rejects corrupted downloads without publishing a runtime", () => @@ -454,7 +456,7 @@ it.effect("rejects corrupted downloads without publishing a runtime", () => expect(state.message).toContain("SHA-256"); expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false); expect(yield* fs.exists(`${installation.managedDirectory}/0.156.1`)).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["interrupted body", "HTTP 404"] as const)( @@ -491,7 +493,7 @@ it.effect.each(["interrupted body", "HTTP 404"] as const)( }); expect(h.downloads()).toBe(1); expect(yield* h.fs.readDirectory(h.installation.managedDirectory)).toEqual([]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -510,7 +512,7 @@ it.effect( }); expect(h.downloads()).toBe(0); expect(yield* fs.readFileString(baseDir)).toBe("existing file"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reports a download timeout and cleans the staging directory", () => @@ -529,7 +531,7 @@ it.effect("reports a download timeout and cleans the staging directory", () => }); expect(h.downloads()).toBe(1); expect(yield* h.fs.readDirectory(h.installation.managedDirectory)).toEqual([]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("cancels an in-flight download and cleans the staging directory", () => @@ -548,7 +550,7 @@ it.effect("cancels an in-flight download and cleans the staging directory", () = ), ).toEqual([]); expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("protects active process leases and removes the runtime after release", () => @@ -564,7 +566,7 @@ it.effect("protects active process leases and removes the runtime after release" expect(yield* fs.exists(installation.managedDirectory)).toBe(false); expect((yield* installation.state).installedVersion).toBeNull(); expect((yield* installation.state).executablePath).toBeNull(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("keeps an activated runtime when a later update fails verification", () => @@ -582,7 +584,7 @@ it.effect("keeps an activated runtime when a later update fails verification", ( yield* update.installation.start(); expect((yield* terminalState(update.installation)).phase).toBe("failed"); expect((yield* update.installation.resolve()).version).toBe("0.156.1"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const releaseMetadata = (version: string, sha256 = "a".repeat(64)) => ({ @@ -648,7 +650,7 @@ it.effect("discovers and installs the latest package without a pinned version", expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); expect(h.releaseChecks()).toBe(1); expect(h.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("starts from a cached verified release when GitHub later rate-limits metadata", () => @@ -682,7 +684,7 @@ it.effect("starts from a cached verified release when GitHub later rate-limits m }); expect(h.releaseChecks()).toBe(1); expect(h.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reports a rate limit when no verified release is cached", () => @@ -705,7 +707,7 @@ it.effect("reports a rate limit when no verified release is cached", () => expect(h.releaseChecks()).toBe(1); expect(h.downloads()).toBe(0); expect((yield* h.installation.state).phase).toBe("idle"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("coalesces concurrent failed release checks during the failure cooldown", () => @@ -731,7 +733,7 @@ it.effect("coalesces concurrent failed release checks during the failure cooldow ); expect(results.every(Exit.isFailure)).toBe(true); expect(h.releaseChecks()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -757,7 +759,7 @@ it.effect.each([ expect(assetRequests[0]?.authorization).toBeUndefined(); expect(h.releaseChecks()).toBe(1); expect(h.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("honors GitHub retry headers during fresh refreshes", () => @@ -797,7 +799,7 @@ it.effect("honors GitHub retry headers during fresh refreshes", () => yield* h.installation.refresh({ fresh: true }); expect((yield* h.installation.state).version).toBe(asset.version); expect(h.releaseChecks()).toBe(2); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("retries a rate-limited discovery immediately when GitHub credentials change", () => @@ -835,7 +837,7 @@ it.effect("retries a rate-limited discovery immediately when GitHub credentials ) .map((request) => request.authorization), ).toEqual([undefined, "Bearer new-github-token"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("retries rejected GitHub credentials anonymously and remembers the rejection", () => @@ -890,7 +892,7 @@ it.effect("retries rejected GitHub credentials anonymously and remembers the rej .filter((request) => request.url.includes("/releases/download/")); expect(assetRequests).toHaveLength(1); expect(assetRequests[0]?.authorization).toBeUndefined(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("briefly caches non-rate-limit discovery failures", () => @@ -919,7 +921,7 @@ it.effect("briefly caches non-rate-limit discovery failures", () => yield* h.installation.refresh({ fresh: true }); expect((yield* h.installation.state).version).toBe(asset.version); expect(h.releaseChecks()).toBe(2); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("manual refresh discovers newer releases while background checks reuse the cache", () => @@ -937,7 +939,7 @@ it.effect("manual refresh discovers newer releases while background checks reuse expect((yield* h.installation.state).version).toBe("0.164.0"); expect(h.releaseChecks()).toBe(2); expect(h.downloads()).toBe(0); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("failed discovery preserves the installed runtime and never downloads a fallback", () => @@ -953,7 +955,7 @@ it.effect("failed discovery preserves the installed runtime and never downloads expect(Exit.isFailure(yield* Effect.exit(h.installation.start()))).toBe(true); expect((yield* h.installation.resolve()).version).toBe(asset.version); expect(h.downloads()).toBe(0); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( "does not downgrade an installed runtime when the release endpoint reports an older version", @@ -970,7 +972,7 @@ it.effect( yield* h.installation.start(); expect((yield* h.installation.resolve()).version).toBe(asset.version); expect(h.downloads()).toBe(0); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Stands in for Windows' bsdtar: CRLF listings, and extraction writes the Windows layout. diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index 11ba19308c079..98abedc48769b 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -12,7 +12,7 @@ import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHub import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -211,7 +211,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function const manifestService = yield* ModelManifest.ModelManifest; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const http = yield* HttpClient.HttpClient; const githubCredentials = yield* GitHubCredentials.GitHubCredentials; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -814,7 +814,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function ) { return yield* SubscriptionRef.get(state); } - const operationId = yield* crypto.randomUUIDv4(); + const operationId = yield* random.uuidV4(); const next: ProviderInstallState = { driver: DRIVER, operationId, diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 341afdd0d5eaa..9ffca8dd694a4 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -4,6 +4,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -273,7 +274,7 @@ it.live.each( Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-managed-runtime-", - }).pipe(Layer.provideMerge(NodeServices.layer)), + }).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ), ); diff --git a/apps/server/src/provider/CodexToolPresentation.ts b/apps/server/src/provider/CodexToolPresentation.ts index 323d068aa067e..19286c6fd1b4f 100644 --- a/apps/server/src/provider/CodexToolPresentation.ts +++ b/apps/server/src/provider/CodexToolPresentation.ts @@ -3,7 +3,7 @@ import type { ToolActivityNativeAppReference, ToolActivitySource, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import type * as EffectCodexSchema from "effect-codex-app-server/schema"; @@ -49,9 +49,9 @@ const nativeAppSourceKey = Effect.fn("CodexToolPresentation.nativeAppSourceKey") ) { const key = `native-app:${appId.toLowerCase()}`; if (key.length <= 512) return key; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const digest = Hex.encode( - yield* crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.orDie), + yield* crypto.digest(Digest.sha256, new TextEncoder().encode(key)).pipe(Effect.orDie), ); return `${key.slice(0, 512 - digest.length - 1)}:${digest}`; }); @@ -133,7 +133,7 @@ export interface McpToolPresentation { export const mcpToolPresentation = Effect.fn("CodexToolPresentation.mcpToolPresentation")( function* ( item: Extract, - ): Effect.fn.Return { + ): Effect.fn.Return { const result = asUnknownRecord(item.result); const metadata = asUnknownRecord(result?._meta); const surface = asUnknownRecord(metadata?.["codex/toolSurface"]); diff --git a/apps/server/src/provider/CodexTurnTokenUsage.ts b/apps/server/src/provider/CodexTurnTokenUsage.ts index 7b0f20a123ffe..7b2524464b0e0 100644 --- a/apps/server/src/provider/CodexTurnTokenUsage.ts +++ b/apps/server/src/provider/CodexTurnTokenUsage.ts @@ -133,27 +133,23 @@ export function accumulateCodexTurnTokenUsage( } } +/** `model` is the thread model Codex last reported, which a reroute replaces. */ export function completeCodexTurnTokenUsage( state: CodexTurnTokenUsageState, turnId: string, completed: boolean, + model?: string, ): TurnTokenUsage { const usage = state.byTurnId.get(turnId); state.byTurnId.delete(turnId); if (state.activeTurnId === turnId) state.activeTurnId = undefined; - if (!usage) { + const models = model === undefined ? {} : { models: [model] }; + if (!usage?.observed) { return { usageStatus: "unavailable", usageScope: "main_agent", - hasSubagents: false, - }; - } - - if (!usage.observed) { - return { - usageStatus: "unavailable", - usageScope: "main_agent", - hasSubagents: usage.hasSubagents, + hasSubagents: usage?.hasSubagents ?? false, + ...models, }; } @@ -169,5 +165,6 @@ export function completeCodexTurnTokenUsage( outputTokens: usage.outputTokens, reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens), hasSubagents: usage.hasSubagents, + ...models, }; } diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 8b6b9b0f0ec6d..935c0c7421c3d 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { @@ -13,6 +14,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Scheduler from "effect/Scheduler"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient } from "effect/http"; @@ -171,20 +173,24 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( yield* controls.beforeProfilePreparation; const handle = yield* spawner.spawn(command); const environment = command.options.env ?? {}; + const env = (name: string) => { + const value = environment[name]; + return Redacted.isRedacted(value) ? Redacted.value(value) : value; + }; launches.push({ command: command.command, - args: command.args, + args: command.args.map(String), cwd: command.options.cwd, extendEnv: command.options.extendEnv, - profileDirectory: environment.GEMINI_HOME, - harnessPath: environment.ANTIGRAVITY_HARNESS_PATH, - forceFileStorage: environment.AGY_ACP_FORCE_FILE_STORAGE, + profileDirectory: env("GEMINI_HOME"), + harnessPath: env("ANTIGRAVITY_HARNESS_PATH"), + forceFileStorage: env("AGY_ACP_FORCE_FILE_STORAGE"), credentialKeys: Object.keys(environment).filter((key) => blockedCredentialKeys.has(key.toUpperCase()), ), - geminiApiKey: environment.GEMINI_API_KEY, + geminiApiKey: env("GEMINI_API_KEY"), tempDirectory: - environment.ANTIGRAVITY_HARNESS_PATH === undefined ? undefined : environment.TMPDIR, + environment.ANTIGRAVITY_HARNESS_PATH === undefined ? undefined : env("TMPDIR"), handle, }); return handle; @@ -254,7 +260,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-driver-config-", }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge( Layer.succeed( HttpClient.HttpClient, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 514ce124ed94d..74fbef4517a38 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -6,7 +6,8 @@ import { NodeRuntimeUnavailableError, nodeRuntimeUnavailableMessage, } from "@t3tools/shared/nodeRuntime"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -76,7 +77,8 @@ export type AntigravityDriverEnv = | AntigravityInstallation.AntigravityInstallation | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | IdAllocator.IdAllocatorV2 @@ -103,7 +105,8 @@ export const AntigravityDriver: ProviderDriver< enabled, config, }) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; + const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; const httpClient = yield* HttpClient.HttpClient; const path = yield* Path.Path; @@ -134,7 +137,7 @@ export const AntigravityDriver: ProviderDriver< host.paths.stateDir, instanceId, ).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), Effect.provideService(Path.Path, path), Effect.mapError( (cause) => @@ -247,6 +250,7 @@ export const AntigravityDriver: ProviderDriver< ); const runtime = yield* makeAntigravityAcpRuntime({ ...input, + runtimeVersion: executable.version, authMethod: auth.authMethod, childProcessSpawner: spawner, spawn: buildAntigravityAcpSpawnInput({ @@ -257,7 +261,7 @@ export const AntigravityDriver: ProviderDriver< auth, runtimeTempDirectory, }), - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + }).pipe(Effect.provideService(SecureRandom.SecureRandom, random)); return { ...runtime, start: () => @@ -350,7 +354,11 @@ export const AntigravityDriver: ProviderDriver< const authFlow: AntigravityAuth = yield* makeAntigravityAuth({ instanceId, makeRuntime: makeDisposableRuntime, - onAuthenticated: publishCatalog, + // A new sign-in may be another account; drop any in-flight quota read first. + onAuthenticated: (started, runtime) => + Effect.suspend(() => provider.onSignedIn).pipe( + Effect.andThen(publishCatalog(started, runtime)), + ), onSignedOut: Effect.suspend(() => provider.onSignedOut), usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), }); @@ -400,7 +408,7 @@ export const AntigravityDriver: ProviderDriver< }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), Effect.provideService(HttpClient.HttpClient, httpClient), ); const provider = yield* makeAntigravityProvider(settings, { @@ -432,6 +440,7 @@ export const AntigravityDriver: ProviderDriver< makeRuntime, withProcess: authFlow.withProcess, defaultModel, + profileDirectory, onSessionStarted: provider.onSessionStarted, onSessionEvent: (event) => { if (event._tag === "ConfigOptionsUpdated") { diff --git a/apps/server/src/provider/Drivers/AntigravityUsage.ts b/apps/server/src/provider/Drivers/AntigravityUsage.ts index 02168fdd83149..5cb55ed77ac09 100644 --- a/apps/server/src/provider/Drivers/AntigravityUsage.ts +++ b/apps/server/src/provider/Drivers/AntigravityUsage.ts @@ -8,7 +8,7 @@ import type { } from "@t3tools/provider-core/server/usage"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +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"; @@ -28,7 +28,7 @@ export class AntigravityUsage extends Context.Service< >()("t3/provider/Drivers/AntigravityUsage") {} const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; @@ -69,7 +69,7 @@ const make = Effect.gen(function* () { host.paths.stateDir, instanceId, ).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), Effect.provideService(Path.Path, path), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.test.ts b/apps/server/src/provider/Drivers/ClaudeDriver.test.ts index 9ba658ce5a688..6cced50eb91df 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off -- the native SDK callback reads the child fixture's account outside Effect. import * as NodeFSP from "node:fs/promises"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; @@ -54,7 +55,7 @@ const layerDeps = Layer.mergeAll( HttpClient.HttpClient, HttpClient.make(() => Effect.die("Fixture must not send HTTP")), ), -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const layerTest = Layer.effect( ProviderHost.ProviderHost, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4f945e47558f1..61ff136e3a7ca 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,7 +1,7 @@ import { ClaudeSettings, ProviderDriverKind, TextGenerationError } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -44,14 +44,7 @@ import { type ProviderInstance, } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { - enrichProviderSnapshotWithVersionAdvisory, - makeCachedProviderMaintenanceResolution, - makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, -} from "@t3tools/provider-core/server/maintenanceResolver"; +import { enrichProviderSnapshotWithVersionAdvisory } from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -62,8 +55,10 @@ import { materializeClaudeHome, makeClaudeContinuationGroupKey, resolveClaudeRuntime, + mergeClaudeProcessEnvironment, } from "./ClaudeHome.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { makeClaudeMaintenanceResolution } from "./ClaudeMaintenance.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -75,30 +70,12 @@ const withoutUsage = (capabilities: ClaudeCapabilitiesProbe): ClaudeCapabilities return result; }; -function isClaudeNativeCommandPath(commandPath: string): boolean { - const normalized = normalizeCommandPath(commandPath); - return ( - normalized.endsWith("/.local/bin/claude") || - normalized.endsWith("/.local/bin/claude.exe") || - normalized.includes("/.local/share/claude/") - ); -} - -const UPDATE = makePackageManagedProviderMaintenanceResolver({ - provider: DRIVER_KIND, - npmPackageName: "@anthropic-ai/claude-code", - nativeUpdate: { - args: ["update"], - isCommandPath: isClaudeNativeCommandPath, - }, -}); - export type ClaudeDriverEnv = | ClaudeAdapterV2DriverEnv | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | ResetCreditCoordinator.ResetCreditCoordinator - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -148,7 +125,7 @@ export const ClaudeDriver: ProviderDriver< enabled, binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), }, - environment: yield* mergeProviderInstanceEnvironment(environment), + environment: yield* mergeClaudeProcessEnvironment(config, environment, process.env), stateDir, }); const { config: effectiveConfig, environment: processEnv, configDir } = runtime; @@ -176,15 +153,14 @@ export const ClaudeDriver: ProviderDriver< driverKind: DRIVER_KIND, instanceId, }); - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), + const resolveMaintenance = yield* makeClaudeMaintenanceResolution({ + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(HttpClient.HttpClient, httpClient), ); const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig, processEnv); const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath( diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 29837febbd3a9..3a0346e051870 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -3,8 +3,8 @@ import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -15,6 +15,7 @@ import { makeClaudeEnvironment, resolveClaudeRuntime, materializeClaudeHome, + mergeClaudeProcessEnvironment, resolveClaudeHomePath, } from "./ClaudeHome.ts"; @@ -163,4 +164,45 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); }); + + it.effect( + "drops the launching Claude session and labels processes t3-code unless overridden", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); + const host = { + CLAUDE_CODE_ENTRYPOINT: "claude-desktop", + CLAUDECODE: "1", + CLAUDE_CODE_SESSION_ID: "host-session", + CLAUDE_CODE_MESSAGING_SOCKET: "/tmp/host-claude.sock", + CLAUDE_EFFORT: "high", + KEEP: "1", + }; + const instance = [ + { name: "CLAUDE_CODE_ENTRYPOINT", value: "instance", sensitive: false }, + { name: "CLAUDE_EFFORT", value: "low", sensitive: false }, + ]; + + expect(yield* mergeClaudeProcessEnvironment({ homePath }, undefined, host)).toEqual({ + CLAUDE_CODE_ENTRYPOINT: "t3-code", + KEEP: "1", + }); + expect(yield* mergeClaudeProcessEnvironment({ homePath }, instance, host)).toEqual({ + CLAUDE_CODE_ENTRYPOINT: "instance", + CLAUDE_EFFORT: "low", + KEEP: "1", + }); + + yield* fileSystem.writeFileString( + path.join(homePath, "settings.json"), + JSON.stringify({ env: { CLAUDE_CODE_ENTRYPOINT: "custom" } }), + ); + expect( + (yield* mergeClaudeProcessEnvironment({ homePath }, instance, host)) + .CLAUDE_CODE_ENTRYPOINT, + ).toBe("custom"); + }), + ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 400b7075881b6..54d15437c8dd3 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,5 +1,9 @@ import type { Settings as ClaudeSdkSettings } from "@anthropic-ai/claude-agent-sdk"; -import type { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import type { + ClaudeSettings, + ProviderInstanceId, + ProviderInstanceEnvironment, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -11,9 +15,12 @@ import { type OverlayPolicy, type OverlayFold, } from "@t3tools/provider-core/server/homeOverlay"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readClaudeSettingsEnv } from "./ClaudeSkills.ts"; + const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); const CLAUDE_BACKEND_ENVIRONMENT = [ @@ -226,6 +233,45 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }; }); +// A Claude Code session that launched T3 exports these. A claude spawned with them acts as part of +// that session: its session id, messaging socket and token, and effort. +const CLAUDE_HOST_SESSION_VARIABLES = [ + "CLAUDECODE", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_HOST_SESSION_ID", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_SESSION_ATTENDED", + "CLAUDE_CODE_MESSAGING_SOCKET", + "CLAUDE_CODE_MESSAGING_TOKEN", + "CLAUDE_CODE_EXECPATH", + "CLAUDE_CODE_DESKTOP_APP_VERSION", + "CLAUDE_EFFORT", + "CLAUDE_PID", + "CLAUDE_AGENT_SDK_VERSION", +]; + +/** + * Base env for the Claude processes T3 Code runs, before `makeClaudeEnvironment`. + * Claude Code reads its entrypoint from the process env, not its settings `env`, so T3 Code applies that override itself. + */ +export const mergeClaudeProcessEnvironment = Effect.fn("mergeClaudeProcessEnvironment")(function* ( + config: Pick, + instanceEnvironment: ProviderInstanceEnvironment | undefined, + hostEnvironment: NodeJS.ProcessEnv, +) { + const baseEnvironment: NodeJS.ProcessEnv = { + ...hostEnvironment, + CLAUDE_CODE_ENTRYPOINT: "t3-code", + }; + for (const name of CLAUDE_HOST_SESSION_VARIABLES) delete baseEnvironment[name]; + const environment = yield* mergeProviderInstanceEnvironment(instanceEnvironment, baseEnvironment); + const settingsEntrypoint = (yield* readClaudeSettingsEnv(config, undefined, environment)) + .CLAUDE_CODE_ENTRYPOINT; + return settingsEntrypoint + ? { ...environment, CLAUDE_CODE_ENTRYPOINT: settingsEntrypoint } + : environment; +}); + /** managed-mcp.json excludes other MCP config and forbids --strict-mcp-config; omit the flag when present. */ export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts new file mode 100644 index 0000000000000..7897fe9b75c5d --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts @@ -0,0 +1,406 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + enrichProviderSnapshotWithVersionAdvisory, + invalidateLatestProviderVersion, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +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 Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeClaudeMaintenanceResolution } from "./ClaudeMaintenance.ts"; + +const snapshot: ServerProvider = { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "2.1.314", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-31T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( + npm = false, + platform: NodeJS.Platform = "linux", + programFiles?: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const binaryPath = npm + ? path.join(directory, "lib/node_modules/@anthropic-ai/claude-code/cli.js") + : path.join(directory, ".local/bin", platform === "win32" ? "claude.exe" : "claude"); + yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + const configDirectory = path.join(directory, "config"); + yield* fs.makeDirectory(configDirectory); + const settingsPath = path.join(configDirectory, "settings.json"); + const managed = new Map(); + const managedDirectory = + platform === "win32" + ? `${programFiles ?? "C:/Program Files"}/ClaudeCode` + : platform === "darwin" + ? "/Library/Application Support/ClaudeCode" + : "/etc/claude-code"; + let installationReads = 0; + const testFs = FileSystem.makeNoop({ + ...fs, + realPath: (file) => { + if (file === binaryPath) installationReads++; + return fs.realPath(file); + }, + readFileString: (file, ...args) => + file.replaceAll("\\", "/").startsWith(`${managedDirectory}/`) + ? managed.has(file.replaceAll("\\", "/")) + ? Effect.succeed(managed.get(file.replaceAll("\\", "/"))!) + : fs.readFileString(path.join(directory, "missing")) + : fs.readFileString(file, ...args), + readDirectory: (file) => + file.replaceAll("\\", "/") === `${managedDirectory}/managed-settings.d` + ? Effect.succeed( + [...managed.keys()] + .filter((key) => key.includes("/managed-settings.d/")) + .map((key) => path.basename(key)), + ) + : fs.readDirectory(file), + }); + return { + directory, + fs, + managed, + managedDirectory, + settingsPath, + installationReads: () => installationReads, + makeResolution: makeClaudeMaintenanceResolution({ binaryPath, env: { PATH: "" } }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("version checks must not run a subprocess")), + ), + Effect.provideService(FileSystem.FileSystem, testFs), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Environment, { + PATH: "", + CLAUDE_CONFIG_DIR: configDirectory, + ...(programFiles ? { PROGRAMFILES: programFiles } : {}), + }), + ), + }; +}); + +it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { + it.effect.each([ + { + name: "default native channel", + settings: undefined, + channel: "latest", + body: "2.1.314\n", + status: 200, + expected: "2.1.314", + advisory: "current", + npm: false, + }, + { + name: "explicit latest channel", + settings: '{"autoUpdatesChannel":"latest"}', + channel: "latest", + body: "2.1.314", + status: 200, + expected: "2.1.314", + advisory: "current", + npm: false, + }, + { + name: "stable channel", + settings: '{"autoUpdatesChannel":"stable"}', + channel: "stable", + body: "2.1.308", + status: 200, + expected: "2.1.308", + advisory: "current", + npm: false, + }, + { + name: "new native release", + settings: undefined, + channel: "latest", + body: "2.1.315", + status: 200, + expected: "2.1.315", + advisory: "behind_latest", + npm: false, + }, + { + name: "unavailable feed", + settings: undefined, + channel: "latest", + body: "Unavailable", + status: 503, + expected: null, + advisory: "unknown", + npm: false, + }, + { + name: "malformed feed", + settings: undefined, + channel: "latest", + body: "error", + status: 200, + expected: null, + advisory: "unknown", + npm: false, + }, + { + name: "empty feed", + settings: undefined, + channel: "latest", + body: " ", + status: 200, + expected: null, + advisory: "unknown", + npm: false, + }, + { + name: "npm install", + settings: '{"autoUpdatesChannel":"stable"}', + channel: "latest", + body: "", + status: 200, + expected: "2.1.316", + advisory: "behind_latest", + npm: true, + }, + ])("advertises the installable version for $name", (row) => + Effect.gen(function* () { + const f = yield* fixture(row.npm); + if (row.settings) yield* f.fs.writeFileString(f.settingsPath, row.settings); + const requests: string[] = []; + const client = HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.includes("claude-code-releases/") + ? new Response(row.body, { status: row.status }) + : Response.json({ version: "2.1.316" }), + ), + ); + }); + const resolve = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const maintenance = yield* resolve(); + const versions = yield* ProviderLatestVersions.make(); + const enrich = enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenance).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, versions), + ); + expect((yield* enrich).versionAdvisory).toMatchObject({ + status: row.advisory, + latestVersion: row.expected, + }); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatch( + row.npm ? /registry\.npmjs\.org/ : new RegExp(`claude-code-releases/${row.channel}$`), + ); + yield* enrich; + expect(requests).toHaveLength(1); + }).pipe(Effect.scoped), + ); + + it.effect.each([ + { name: "Linux", platform: "linux", programFiles: undefined }, + { name: "macOS", platform: "darwin", programFiles: undefined }, + { name: "Windows default path", platform: "win32", programFiles: undefined }, + { name: "Windows custom path", platform: "win32", programFiles: "D:/Programs" }, + ] as const)( + "separates channel caches and honors $name managed overrides and feed invalidation", + (row) => + Effect.gen(function* () { + const f = yield* fixture(false, row.platform, row.programFiles); + const requests: string[] = []; + const client = HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(request.url.endsWith("/stable") ? "2.1.308" : "2.1.314"), + ), + ); + }); + const versions = yield* ProviderLatestVersions.make(); + const resolve = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const check = Effect.gen(function* () { + const capabilities = yield* resolve(); + return yield* enrichProviderSnapshotWithVersionAdvisory(snapshot, capabilities); + }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, versions), + ); + expect((yield* check).versionAdvisory?.latestVersion).toBe("2.1.314"); + yield* f.fs.writeFileString(f.settingsPath, '{"autoUpdatesChannel":"stable"}'); + expect((yield* check).versionAdvisory?.latestVersion).toBe("2.1.308"); + f.managed.set( + `${f.managedDirectory}/managed-settings.json`, + '{"autoUpdatesChannel":"latest"}', + ); + expect((yield* check).versionAdvisory?.latestVersion).toBe("2.1.314"); + f.managed.set( + `${f.managedDirectory}/managed-settings.d/20-channel.json`, + '{"autoUpdatesChannel":"stable"}', + ); + f.managed.set( + `${f.managedDirectory}/managed-settings.d/10-channel.json`, + '{"autoUpdatesChannel":"latest"}', + ); + expect((yield* check).versionAdvisory?.latestVersion).toBe("2.1.308"); + expect(requests).toHaveLength(2); + const capabilities = yield* resolve(); + yield* invalidateLatestProviderVersion(capabilities).pipe( + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, versions), + ); + yield* check; + expect(requests).toHaveLength(3); + }).pipe(Effect.scoped), + ); + + it.effect("automatic refresh rereads the native channel while preserving both caches", () => + Effect.gen(function* () { + const f = yield* fixture(); + const requests: string[] = []; + const client = HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(request.url.endsWith("/stable") ? "2.1.308" : "2.1.315"), + ), + ); + }); + const resolveMaintenance = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const versions = yield* ProviderLatestVersions.make(); + const publications = yield* Queue.unbounded(); + yield* makeManagedServerProvider({ + resolveMaintenance, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(snapshot), + checkProvider: Effect.succeed(snapshot), + refreshInterval: "1 minute", + enrichSnapshot: ({ snapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenance) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenance), + ), + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, versions), + Effect.flatMap((enriched) => + publishSnapshot(enriched).pipe( + Effect.andThen(Queue.offer(publications, enriched)), + Effect.asVoid, + ), + ), + ), + }).pipe( + Effect.provide( + Layer.mock(ProviderHost.ProviderHost)({ + paths: { + cwd: f.directory, + baseDir: f.directory, + stateDir: f.directory, + providerStatusCacheDir: f.directory, + attachmentsDir: f.directory, + }, + settings: { + get: Effect.succeed(DEFAULT_SERVER_SETTINGS), + withSnapshot: (use) => use(DEFAULT_SERVER_SETTINGS), + changes: Stream.empty, + subscribe: Effect.succeed(Stream.empty), + }, + shouldRunBackgroundWork: () => Effect.succeed(true), + resolveAttachmentPath: () => null, + }), + ), + ); + expect((yield* Queue.take(publications)).versionAdvisory).toMatchObject({ + latestVersion: "2.1.315", + status: "behind_latest", + }); + expect(requests).toHaveLength(1); + + for (const channel of ["stable", "stable", "latest"] as const) { + yield* f.fs.writeFileString( + f.settingsPath, + JSON.stringify({ autoUpdatesChannel: channel }), + ); + yield* TestClock.adjust("1 minute"); + expect((yield* Queue.take(publications)).versionAdvisory).toMatchObject({ + latestVersion: channel === "stable" ? "2.1.308" : "2.1.315", + status: channel === "stable" ? "current" : "behind_latest", + }); + expect(requests).toEqual([ + "https://downloads.claude.ai/claude-code-releases/latest", + "https://downloads.claude.ai/claude-code-releases/stable", + ]); + expect(f.installationReads()).toBe(1); + } + }).pipe(Effect.scoped), + ); + + it.effect("bounds stalled feed bodies and skips disabled update checks", () => + Effect.gen(function* () { + const f = yield* fixture(); + let requests = 0; + const client = HttpClient.make((request) => { + requests++; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(new ReadableStream())), + ); + }); + const resolve = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const capabilities = yield* resolve(); + const enrich = (enabled: boolean) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, capabilities, { + enableProviderUpdateChecks: enabled, + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect((yield* enrich(false)).versionAdvisory?.status).toBe("unknown"); + expect(requests).toBe(0); + const pending = yield* enrich(true).pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("4 seconds"); + expect((yield* Fiber.join(pending)).versionAdvisory?.status).toBe("unknown"); + expect(requests).toBe(1); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts new file mode 100644 index 0000000000000..d9975762647d4 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts @@ -0,0 +1,116 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { + makeCachedProviderMaintenanceResolution, + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, + type ProviderMaintenanceCapabilities, + type ProviderMaintenanceResolverServices, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { resolveClaudeHomePath } from "./ClaudeHome.ts"; + +const NATIVE_RELEASES = "https://downloads.claude.ai/claude-code-releases"; +const UpdateChannelSettings = fromLenientJson( + Schema.Struct({ autoUpdatesChannel: Schema.optional(Schema.Literals(["latest", "stable"])) }), +); +const NativeVersion = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+$/)); + +const update = makePackageManagedProviderMaintenanceResolver({ + provider: ProviderDriverKind.make("claudeAgent"), + npmPackageName: "@anthropic-ai/claude-code", + nativeUpdate: { + args: ["update"], + isCommandPath: (commandPath) => { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.local/bin/claude") || + normalized.endsWith("/.local/bin/claude.exe") || + normalized.includes("/.local/share/claude/") + ); + }, + }, +}); + +const readUpdateChannel = Effect.fn("ClaudeMaintenance.readUpdateChannel")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcess.Environment; + const platform = yield* HostProcess.Platform; + const managedDirectory = + platform === "darwin" + ? "/Library/Application Support/ClaudeCode" + : platform === "win32" + ? path.join(environment.PROGRAMFILES || "C:\\Program Files", "ClaudeCode") + : "/etc/claude-code"; + const dropInDirectory = path.join(managedDirectory, "managed-settings.d"); + const dropIns = yield* fs.readDirectory(dropInDirectory).pipe(Effect.orElseSucceed(() => [])); + // The updater inherits the server's config directory, not the instance's homePath. + const configDirectory = yield* resolveClaudeHomePath({ homePath: "" }, environment); + const settingsPaths = [ + path.join(configDirectory, "settings.json"), + path.join(managedDirectory, "managed-settings.json"), + ...dropIns + .filter((name) => name.endsWith(".json") && !name.startsWith(".")) + .toSorted() + .map((name) => path.join(dropInDirectory, name)), + ]; + const channels = yield* Effect.forEach(settingsPaths, (settingsPath) => + fs.readFileString(settingsPath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(UpdateChannelSettings)), + Effect.map((settings) => settings.autoUpdatesChannel), + Effect.orElseSucceed(() => undefined), + ), + ); + return channels.findLast((channel) => channel !== undefined) ?? "latest"; +}); + +const resolveClaudeMaintenance = Effect.fn("ClaudeMaintenance.resolve")(function* ( + capabilities: ProviderMaintenanceCapabilities, +) { + if (capabilities.update?.lockKey !== "claudeAgent-native") return capabilities; + const channel = yield* readUpdateChannel(); + const client = yield* HttpClient.HttpClient; + const url = `${NATIVE_RELEASES}/${channel}`; + return { + ...capabilities, + latestVersion: null, + latestVersionCacheKey: url, + lookupLatestVersion: client.get(url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.text), + Effect.map((text) => text.trim()), + Effect.flatMap(Schema.decodeUnknownEffect(NativeVersion)), + Effect.timeoutOption("4 seconds"), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ), + }; +}); + +export const makeClaudeMaintenanceResolution = Effect.fn("ClaudeMaintenance.make")(function* ( + options: Parameters[1], +) { + const services = yield* Effect.context< + ProviderMaintenanceResolverServices | HttpClient.HttpClient + >(); + const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(update, options).pipe( + Effect.provideContext(services), + ), + ); + const resolve: typeof resolveInstallation = (refreshOptions) => + resolveInstallation(refreshOptions).pipe( + Effect.flatMap(resolveClaudeMaintenance), + Effect.provideContext(services), + ); + return resolve; +}); diff --git a/apps/server/src/provider/Drivers/ClaudeProcess.ts b/apps/server/src/provider/Drivers/ClaudeProcess.ts index ab5b7bf85bf6b..ead9c0771d25c 100644 --- a/apps/server/src/provider/Drivers/ClaudeProcess.ts +++ b/apps/server/src/provider/Drivers/ClaudeProcess.ts @@ -29,15 +29,12 @@ export function spawnClaudeProcess( cwd: options.cwd, env: configDir === undefined ? options.env : claudeCliEnvironment(options.env, configDir), signal: options.signal, - stdio: - platform === "win32" - ? ["pipe", "pipe", onStderr === undefined ? "ignore" : "pipe"] - : ["pipe", "pipe", "ignore", "pipe"], + stdio: platform === "win32" ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", "pipe"], detached: platform !== "win32", windowsHide: true, }, ); - if (onStderr !== undefined) child.stderr?.setEncoding("utf8").on("data", onStderr); + child.stderr?.setEncoding("utf8").on("data", onStderr ?? (() => {})); child.once("exit", () => { onExit?.(); if (platform === "win32") return; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index d677a884198a9..57d1353d49a9d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -356,24 +356,25 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); -const SettingsBaseUrl = fromLenientJson( - Schema.Struct({ - env: Schema.optional(Schema.Struct({ ANTHROPIC_BASE_URL: Schema.optional(Schema.String) })), - }), +const SettingsEnv = Schema.Struct({ + ANTHROPIC_BASE_URL: Schema.optional(Schema.String), + CLAUDE_CODE_ENTRYPOINT: Schema.optional(Schema.String), +}); +const decodeSettingsEnv = Schema.decodeUnknownEffect( + fromLenientJson(Schema.Struct({ env: Schema.optional(SettingsEnv) })), ); -const decodeSettingsBaseUrl = Schema.decodeUnknownEffect(SettingsBaseUrl); -/** ANTHROPIC_BASE_URL from Claude's settings `env`, which Claude Code applies over the process env. */ -export const readClaudeSettingsBaseUrl = Effect.fn("readClaudeSettingsBaseUrl")(function* ( +/** The `env` values T3 Code reads from Claude's user and managed settings; later files win. */ +export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function* ( config: Pick, cwd: string | undefined, environment: NodeJS.ProcessEnv, -): Effect.fn.Return { +): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); - let baseUrl: string | undefined; + const settingsEnv: typeof SettingsEnv.Type = {}; for (const settingsPath of claudeSettingsPaths( path, configDirPath, @@ -385,12 +386,10 @@ export const readClaudeSettingsBaseUrl = Effect.fn("readClaudeSettingsBaseUrl")( .readFileString(settingsPath) .pipe(Effect.orElseSucceed(() => undefined)); if (contents === undefined) continue; - const parsed = yield* decodeSettingsBaseUrl(contents).pipe( - Effect.orElseSucceed(() => undefined), - ); - baseUrl = parsed?.env?.ANTHROPIC_BASE_URL ?? baseUrl; + const parsed = yield* decodeSettingsEnv(contents).pipe(Effect.orElseSucceed(() => undefined)); + Object.assign(settingsEnv, parsed?.env); } - return baseUrl; + return settingsEnv; }); // Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index bf9c61dd0eb43..57a00165777e4 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -3,6 +3,7 @@ import * as CodexManagedTokenBroker from "../CodexManagedTokenBroker.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; // @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodePath from "node:path"; import { expect, it } from "@effect/vitest"; @@ -46,7 +47,7 @@ import * as ProviderHostLive from "../ProviderHostLive.ts"; const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-driver-maintenance-", }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(IdAllocator.layer), Layer.provideMerge(McpProviderSessions.layer), Layer.provideMerge( @@ -708,7 +709,7 @@ it.layer(layerTest)("CodexDriver", (it) => { if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { return Effect.die("Provider resolution must not execute a provider or updater"); } - probes.push(command.args); + probes.push(command.args.map(String)); const stdout = command.args[0] === "--prefix" ? brewPrefix diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 6d5f004a4e0ae..54f93d1b38843 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,6 +1,7 @@ import { CodexSettings, ProviderDriverKind, TextGenerationError } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; import { resolveCommandPath } from "@t3tools/shared/shell"; +import * 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 Path from "effect/Path"; @@ -118,7 +119,8 @@ export type CodexDriverEnv = | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | ResetCreditCoordinator.ResetCreditCoordinator - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index 3820ce0cf5234..74812bafb5099 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -1,7 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; import { totalTokens } from "@t3tools/provider-core/server/usage"; -import { codexUsageFormat, initialCodexScanState, parseCodexLine } from "./codexUsage.ts"; +import { + type CodexScanState, + codexUsageFormat, + initialCodexScanState, + parseCodexLine as parseCodexRecords, +} from "./codexUsage.ts"; + +function parseCodexLine(line: string, state: CodexScanState) { + const records = parseCodexRecords(line, state); + expect(records.length).toBeLessThanOrEqual(1); + return records[0] ?? null; +} describe("parseCodexLine", () => { const sessionMeta = JSON.stringify({ @@ -326,6 +337,119 @@ describe("parseCodexLine", () => { expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(1); }); + it("bills only the reported request when usage records sit between reconciling snapshots", () => { + const state = initialCodexScanState(); + const usageRecord = (usage: unknown) => + JSON.stringify({ + type: "token_usage_record", + timestamp: "2026-08-01T05:17:45.000Z", + payload: { usage }, + }); + parseCodexLine(turnContext, state); + parseCodexLine(counted(counters(100, 20), counters(100, 20)), state); + for (const usage of [counters(40, 5), counters(60, 15), counters(30, 3)]) { + expect(parseCodexLine(usageRecord(usage), state)).toBeNull(); + } + expect( + parseCodexLine(counted(counters(100, 20), counters(200, 40)), state)?.totals.outputTokens, + ).toBe(20); + // Records explained by an earlier snapshot cannot bridge a later gap. + parseCodexLine(usageRecord(counters(10, 2)), state); + expect(parseCodexLine(counted(counters(10, 2), counters(340, 65)), state)).toBeNull(); + expect(state.malformedRecords).toBe(1); + }); + + it("bills each interrupted turn's request with the model, tier and time it was read under", () => { + const state = initialCodexScanState(); + const line = (type: string, time: string, payload: Record) => + JSON.stringify({ type, timestamp: `2026-11-0${time}Z`, payload }); + const context = (time: string, turn_id: string, model: string) => + line("turn_context", time, { turn_id, model }); + const tier = (time: string, service_tier: string) => + line("event_msg", time, { + type: "thread_settings_applied", + thread_settings: { service_tier }, + }); + const request = (time: string, usage: unknown) => line("token_usage_record", time, { usage }); + const snapshot = (time: string, last: unknown, total: unknown) => + line("event_msg", time, { + type: "token_count", + info: { last_token_usage: last, total_token_usage: total }, + }); + const parsed = [ + line("session_meta", "1T23:59:50", { id: "session-a", model_provider: "openai" }), + context("1T23:59:50", "turn-1", "gpt-6-sol"), + request("1T23:59:51", counters(100, 20)), + snapshot("1T23:59:52", counters(100, 20), counters(100, 20)), + // One interrupted turn whose requests straddle midnight. + request("1T23:59:59", counters(150, 10)), + request("2T00:00:01", counters(50, 5)), + tier("2T00:00:02", "priority"), + context("2T00:00:02", "turn-2", "gpt-6-astra"), + request("2T00:00:03", counters(50, 5)), + request("2T00:00:04", counters(30, 5)), + tier("2T00:00:05", "default"), + context("2T00:00:05", "turn-3", "gpt-6-sol"), + request("2T00:00:06", counters(200, 30)), + snapshot("2T00:00:07", counters(200, 30), counters(580, 75)), + ].flatMap((value) => parseCodexRecords(value, state)); + + const at = (time: string) => Date.parse(`2026-11-0${time}Z`); + expect( + parsed.map((record) => ({ + tokens: totalTokens(record.totals), + model: record.model, + speed: record.speed, + timestampMs: record.timestampMs, + unresolvedServiceTierTurnId: record.unresolvedServiceTierTurnId, + })), + ).toEqual([ + { + tokens: 120, + model: "gpt-6-sol", + speed: "standard", + timestampMs: at("1T23:59:52"), + unresolvedServiceTierTurnId: "turn-1", + }, + { + tokens: 160, + model: "gpt-6-sol", + speed: "standard", + timestampMs: at("1T23:59:59"), + unresolvedServiceTierTurnId: "turn-1", + }, + { + tokens: 55, + model: "gpt-6-sol", + speed: "standard", + timestampMs: at("2T00:00:01"), + unresolvedServiceTierTurnId: "turn-1", + }, + { + tokens: 55, + model: "gpt-6-astra", + speed: "fast", + timestampMs: at("2T00:00:03"), + unresolvedServiceTierTurnId: undefined, + }, + { + tokens: 35, + model: "gpt-6-astra", + speed: "fast", + timestampMs: at("2T00:00:04"), + unresolvedServiceTierTurnId: undefined, + }, + { + tokens: 230, + model: "gpt-6-sol", + speed: "standard", + timestampMs: at("2T00:00:07"), + unresolvedServiceTierTurnId: undefined, + }, + ]); + expect(state.malformedRecords).toBe(0); + }); + it("does not bill a context-window estimate or lose the following real request", () => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); @@ -664,5 +788,26 @@ describe("parseCodexLine", () => { skipped: codexUsageFormat.readMalformedRecords?.(state), }).toEqual({ processed: [120, 0, 120, 35], skipped: 0 }); }); + + it("bills the child's first request when its turn is interrupted before any snapshot", () => { + const state = initialCodexScanState(); + const forkInstant = "2026-08-01T05:00:00.000Z"; + const request = (timestamp: string, usage: unknown) => + JSON.stringify({ type: "token_usage_record", timestamp, payload: { usage } }); + const billed = [ + meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), + stamped(forkInstant, turnContext), + request("2026-08-01T05:00:00.001Z", counters(100, 20)), + stamped("2026-08-01T05:00:00.001Z", counted(counters(100, 20), counters(100, 20))), + request("2026-08-01T05:00:06.000Z", counters(150, 10)), + request("2026-08-01T05:00:10.000Z", counters(200, 30)), + stamped("2026-08-01T05:00:10.000Z", counted(counters(200, 30), counters(450, 60))), + ].flatMap((line) => parseCodexRecords(line, state)); + + expect({ + processed: billed.map((record) => totalTokens(record.totals)), + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed: [160, 230], skipped: 0 }); + }); }); }); diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index e4e9cb763648e..1c73fcd4d0e57 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -5,7 +5,6 @@ import * as Schema from "effect/Schema"; import { parseTimestampMs, - totalTokens, type ProviderUsageReader, type TranscriptUsageFormat, type UsageRecord, @@ -58,6 +57,7 @@ function readCodexTokenUsage(value: unknown): CodexTokenUsage | null { // A zeroed baseline rebases the counters, so the sequence start measured against it is forgotten. function rebaseCodexUsage(state: CodexScanState, cumulative: CodexTokenUsage) { state.lastCumulativeUsage = cumulative; + delete state.unreportedRequests; if (cumulative.input_tokens + cumulative.output_tokens === 0) delete state.sequenceStartSignature; } @@ -67,18 +67,66 @@ function advanceCodexUsage(previous: CodexTokenUsage | null, last: CodexTokenUsa ); } +const CodexSpeed = Schema.Literals(["standard", "fast", "ultrafast"]); + +// What a request is billed as is fixed when it is read, not when a later snapshot reconciles it. +const CodexUnreportedRequest = Schema.Struct({ + timestampMs: Schema.Finite, + model: Schema.String, + turnId: Schema.String, + speed: CodexSpeed, + sawThreadSettings: Schema.Boolean, + usage: CodexTokenUsage, +}); +type CodexUnreportedRequest = typeof CodexUnreportedRequest.Type; +type CodexRequestAttribution = Omit; + +// An interrupted turn can end before a snapshot reports its last request. Bill those requests only +// when the `token_usage_record`s since the previous snapshot explain the whole gap; `last` is the +// final one. +function unreportedRequests( + previous: CodexTokenUsage | null, + cumulative: CodexTokenUsage, + last: CodexTokenUsage, + pending: readonly CodexUnreportedRequest[] | undefined, +) { + if ( + previous === null || + pending === undefined || + CODEX_USAGE_FIELDS.some( + (key) => + cumulative[key] - previous[key] !== + pending.reduce((sum, request) => sum + request.usage[key], 0), + ) + ) { + return null; + } + const final = pending.at(-1)!; + const remainder = readCodexTokenUsage( + Object.fromEntries(CODEX_USAGE_FIELDS.map((key) => [key, final.usage[key] - last[key]])), + ); + if (remainder === null) return null; + return [...pending.slice(0, -1), { ...final, usage: remainder }].filter( + (request) => request.usage.input_tokens + request.usage.output_tokens > 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`. */ export const CodexScanState = Schema.Struct({ model: Schema.mutableKey(Schema.String), - speed: Schema.mutableKey(Schema.Literals(["standard", "fast", "ultrafast"])), + speed: Schema.mutableKey(CodexSpeed), sessionId: Schema.mutableKey(Schema.String), lastUsageSignature: Schema.mutableKey(Schema.NullOr(Schema.String)), lastCumulativeUsage: Schema.mutableKey(Schema.NullOr(CodexTokenUsage)), // Optional so scan states cached before this field existed stay valid. sequenceStartSignature: Schema.mutableKey(Schema.optionalKey(Schema.String)), + // `token_usage_record` usage since the last cumulative snapshot, one entry per record. + unreportedRequests: Schema.mutableKey( + Schema.optionalKey(Schema.mutable(Schema.Array(CodexUnreportedRequest))), + ), malformedRecords: Schema.mutableKey(TokenCount), sawSessionMeta: Schema.mutableKey(Schema.Boolean), modelProvider: Schema.mutableKey(Schema.String), @@ -120,32 +168,44 @@ function isForkedSessionMeta(payload: Record): boolean { return typeof (spawn as Record)["parent_thread_id"] === "string"; } +// The first usage line past the copied burst ends suppression for the rest of the rollout. +function isForkCopy(state: CodexScanState, timestampMs: number): boolean { + if (!state.suppressingForkCopies) return false; + if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { + state.forkCopyAnchorMs = timestampMs; + return true; + } + state.suppressingForkCopies = false; + state.lastUsageSignature = null; + return false; +} + /** * Cumulative counters validate request deltas. Older events without cumulative counters use * normalized consecutive-request deduplication. */ -export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { +export function parseCodexLine(line: string, state: CodexScanState): readonly UsageRecord[] { let parsed: unknown; try { parsed = JSON.parse(line); } catch { - return null; + return []; } return parseCodexRecord(parsed, state); } -function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { - if (typeof parsed !== "object" || parsed === null) return null; +function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly UsageRecord[] { + if (typeof parsed !== "object" || parsed === null) return []; const record = parsed as Record; const payload = record["payload"]; - if (typeof payload !== "object" || payload === null) return null; + if (typeof payload !== "object" || payload === null) return []; const payloadRecord = payload as Record; const payloadType = payloadRecord["type"]; if (record["type"] === "session_meta") { // Only the first meta identifies this session; copied ancestor metas would misattribute later records. - if (state.sawSessionMeta) return null; + if (state.sawSessionMeta) return []; state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; if (typeof id === "string") state.sessionId = id; @@ -157,7 +217,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | state.suppressingForkCopies = true; state.forkCopyAnchorMs = metaTimestampMs; } - return null; + return []; } if (record["type"] === "turn_context") { @@ -169,7 +229,18 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | payloadRecord["model"].length > 0 ? payloadRecord["turn_id"] : ""; - return null; + return []; + } + + // Several of these may sit between two snapshots, so they only explain a gap in the counters. + if (record["type"] === "token_usage_record") { + const timestampMs = parseTimestampMs(record["timestamp"]); + const usage = readCodexTokenUsage(payloadRecord["usage"]); + if (timestampMs === null || usage === null || state.model.length === 0) return []; + // Copied history is already billed in the parent. + if (isForkCopy(state, timestampMs)) return []; + (state.unreportedRequests ??= []).push({ ...codexAttribution(state, timestampMs), usage }); + return []; } if (payloadType === "thread_settings_applied") { @@ -178,53 +249,49 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | if (typeof settings === "object" && settings !== null) { state.speed = codexSpeed((settings as Record)["service_tier"]); } - return null; + return []; } - if (payloadType !== "token_count") return null; + if (payloadType !== "token_count") return []; const info = payloadRecord["info"]; - if (typeof info !== "object" || info === null) return null; + if (typeof info !== "object" || info === null) return []; const infoRecord = info as Record; // Codex may re-emit these counters after the model or timestamp becomes available. const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null || state.model.length === 0) return null; + if (timestampMs === null || state.model.length === 0) return []; const last = readCodexTokenUsage(infoRecord["last_token_usage"]); const rawCumulative = infoRecord["total_token_usage"]; const hasCumulative = rawCumulative !== undefined && rawCumulative !== null; const cumulative = hasCumulative ? readCodexTokenUsage(rawCumulative) : null; - if (state.suppressingForkCopies) { - if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { - state.forkCopyAnchorMs = timestampMs; - // Copied history is already billed in the parent, but establishes the child's baseline. - const signature = last === null ? null : JSON.stringify(last); - if (cumulative !== null) rebaseCodexUsage(state, cumulative); - else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) { - state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last); - } - state.lastUsageSignature = signature; - return null; + if (isForkCopy(state, timestampMs)) { + // Copied history is already billed in the parent, but establishes the child's baseline. + const signature = last === null ? null : JSON.stringify(last); + if (cumulative !== null) rebaseCodexUsage(state, cumulative); + else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) { + state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last); } - state.suppressingForkCopies = false; - state.lastUsageSignature = null; + state.lastUsageSignature = signature; + return []; } const previous = state.lastCumulativeUsage; + const pending = state.unreportedRequests; // A valid snapshot remains the next baseline even when its paired request is malformed. if (cumulative !== null) rebaseCodexUsage(state, cumulative); if (last === null || (hasCumulative && cumulative === null)) { state.malformedRecords += 1; - return null; + return []; } // Zero-component counters report context size, not a request: fill_to_context_window, or the // post-compaction marker whose total is the new context size (its cumulative may reset to zero). if (last.input_tokens + last.output_tokens === 0) { state.lastUsageSignature = null; - return null; + return []; } if ( @@ -232,15 +299,16 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | previous !== null && CODEX_USAGE_FIELDS.every((key) => cumulative[key] === previous[key]) ) { - return null; + return []; } if (last.total_tokens !== last.input_tokens + last.output_tokens) { state.malformedRecords += 1; - return null; + return []; } const signature = JSON.stringify(last); + let unreported: readonly CodexUnreportedRequest[] = []; if (cumulative !== null) { // A session resumed in a new Codex process restarts its cumulative counters at this request. const restarted = CODEX_USAGE_FIELDS.every((key) => cumulative[key] === last[key]); @@ -248,7 +316,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | // Only the same event is a replay; a later request can restart with identical counters. if (restarted && sequenceStart === state.sequenceStartSignature) { state.lastCumulativeUsage = previous; - return null; + return []; } // Forks and paginated continuations can inherit totals without copying a counter snapshot. // Only bill the latest request, and require the unseen prefix to be valid usage too. @@ -259,26 +327,51 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | ) !== null : CODEX_USAGE_FIELDS.every((key) => cumulative[key] - previous[key] === last[key]); if (!restarted && !reconciled) { - state.malformedRecords += 1; - return null; + const bridged = unreportedRequests(previous, cumulative, last, pending); + if (bridged === null) { + state.malformedRecords += 1; + return []; + } + unreported = bridged; } if (restarted) state.sequenceStartSignature = sequenceStart; } else { - if (signature === state.lastUsageSignature) return null; + if (signature === state.lastUsageSignature) return []; // Advance expected totals for billed legacy requests so a cumulative re-emission is stale. const advanced = advanceCodexUsage(previous, last); if (advanced === null) { state.malformedRecords += 1; - return null; + return []; } state.lastCumulativeUsage = advanced; } state.lastUsageSignature = signature; - const inputTokens = last.input_tokens; - const cachedInputTokens = last.cached_input_tokens; - const cacheCreationTokens = last.cache_write_input_tokens; - const outputTokens = last.output_tokens; + return [ + ...unreported.map((request) => codexRequestRecord(state, request.usage, request)), + codexRequestRecord(state, last, codexAttribution(state, timestampMs)), + ]; +} + +function codexAttribution(state: CodexScanState, timestampMs: number): CodexRequestAttribution { + return { + timestampMs, + model: state.model, + turnId: state.turnId, + speed: state.speed, + sawThreadSettings: state.sawThreadSettings, + }; +} + +function codexRequestRecord( + state: CodexScanState, + usage: CodexTokenUsage, + { timestampMs, model, turnId, speed, sawThreadSettings }: CodexRequestAttribution, +): UsageRecord { + const inputTokens = usage.input_tokens; + const cachedInputTokens = usage.cached_input_tokens; + const cacheCreationTokens = usage.cache_write_input_tokens; + const outputTokens = usage.output_tokens; const totals: UsageTokenTotals = { // Codex reports `input_tokens` inclusive of the cached portion. @@ -287,24 +380,22 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | cacheCreationTokens, outputTokens, // Reported inside output_tokens, surfaced separately for the token mix. - reasoningTokens: last.reasoning_output_tokens, + reasoningTokens: usage.reasoning_output_tokens, }; - if (totalTokens(totals) === 0) return null; - return { provider: "codex", timestampMs, - model: state.model, + model, sessionId: state.sessionId, totals, reportedCostUsd: null, - speed: state.speed, - ...(!state.sawThreadSettings && + speed, + ...(!sawThreadSettings && state.modelProvider === "openai" && state.sessionId.length > 0 && - state.turnId.length > 0 - ? { unresolvedServiceTierTurnId: state.turnId } + turnId.length > 0 + ? { unresolvedServiceTierTurnId: turnId } : {}), dedupeKey: null, }; @@ -317,9 +408,6 @@ function codexSpeed(serviceTier: unknown): UsageSpeed { return "standard"; } -const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] => - record === null ? [] : [record]; - export const codexUsageFormat: TranscriptUsageFormat = { selectFields: { type: true, @@ -335,12 +423,19 @@ export const codexUsageFormat: TranscriptUsageFormat = { forked_from_id: true, source: { subagent: { thread_spawn: { parent_thread_id: true } } }, info: { last_token_usage: true, total_token_usage: true }, + usage: true, }, }, // Metadata lines carry model, tier and session even without usage. - usageMarkers: ['"token_count"', '"turn_context"', '"thread_settings_applied"', '"session_meta"'], - parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), - parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), + usageMarkers: [ + '"token_count"', + '"token_usage_record"', + '"turn_context"', + '"thread_settings_applied"', + '"session_meta"', + ], + parseLine: parseCodexLine, + parseProjected: parseCodexRecord, state: { initial: initialCodexScanState, schema: CodexScanState }, readMalformedRecords: (state) => state.malformedRecords, // A rollout that moved after it was read leaves a copy in both places. diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 34b90a655322e..9cc1a9a7e3238 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -2,7 +2,9 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -196,7 +198,7 @@ describe("buildGrokModelCapabilities", () => { }); }); -it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("checkGrokProviderStatus", (it) => { it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => Effect.gen(function* () { const secretStderr = "broken grok install: secret-token-value"; @@ -551,7 +553,7 @@ describe("Grok usage limits", () => { }); }); -it.layer(NodeServices.layer)("readGrokAccount", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("readGrokAccount", (it) => { it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -722,7 +724,7 @@ it.layer(NodeServices.layer)("readGrokAccount", (it) => { ); }); -it.layer(NodeServices.layer)("readGrokAccount email", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("readGrokAccount email", (it) => { it.effect("names the account whose limits were read, and nothing for other auth", () => Effect.gen(function* () { const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; diff --git a/apps/server/src/provider/KiroProvider.test.ts b/apps/server/src/provider/KiroProvider.test.ts new file mode 100644 index 0000000000000..b0b9143d97a55 --- /dev/null +++ b/apps/server/src/provider/KiroProvider.test.ts @@ -0,0 +1,448 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { KiroSettings } from "@t3tools/provider-kiro/settings"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { + checkKiroProviderStatus, + kiroAgentDescriptor, + kiroDefaultModelId, +} from "@t3tools/provider-kiro/testing"; + +const encoder = new TextEncoder(); +const settings = Schema.decodeSync(KiroSettings)({ enabled: true }); + +interface CliOutput { + readonly stdout: string; + readonly code: number; +} + +function processHandle(output: CliOutput) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_002), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: + output.stdout.length === 0 ? Stream.empty : Stream.succeed(encoder.encode(output.stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +const KIRO_ACP_ARGS = "acp --agent-engine=v3 --auth-method=cli"; +const decodeRequest = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ id: Schema.Union([Schema.String, Schema.Number]), method: Schema.String }), + ), +); + +/** + * A `kiro-cli acp` that answers `initialize` and `_kiro/config/template` and + * fails anything else, so a probe that opened a session would fail. + */ +const acpProcess = (template: unknown) => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const exited = yield* Deferred.make(); + const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( + Effect.andThen(Queue.shutdown(output)), + Effect.asVoid, + ); + const reply = (line: string) => { + const request = decodeRequest(line); + const response = + request.method === "initialize" + ? { result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } } + : request.method === "_kiro/config/template" + ? { result: template } + : { error: { code: -32601, message: `${request.method} is not scripted` } }; + return Queue.offer( + output, + encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, ...response })}\n`), + ); + }; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_003), + exitCode: Deferred.await(exited), + isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), + kill: () => terminate, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((bytes: Uint8Array) => + Effect.forEach( + new TextDecoder() + .decode(bytes) + .split("\n") + .filter((line) => line.trim().length > 0), + reply, + ), + ), + stdout: Stream.fromQueue(output), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }); + +/** Answers `kiro-cli` invocations by their arguments and records which ran. */ +function kiroCli(outputs: Record, template?: unknown) { + const invoked: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args.join(" ") : ""; + invoked.push(args); + return args === KIRO_ACP_ARGS && template !== undefined + ? acpProcess(template) + : Effect.succeed(processHandle(outputs[args] ?? { stdout: "", code: 127 })); + }); + return { invoked, spawner }; +} + +const check = ( + outputs: Record, + options: { readonly template?: unknown; readonly settings?: KiroSettings } = {}, +) => + Effect.gen(function* () { + const cli = kiroCli(outputs, options.template); + const snapshot = yield* checkKiroProviderStatus(options.settings ?? settings, {}).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, cli.spawner), + ); + return { snapshot, invoked: cli.invoked }; + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); + +describe("KiroProvider", () => { + it.effect("reports a CLI whose version probe exits nonzero as failing to run", () => + Effect.gen(function* () { + const { snapshot, invoked } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 1 }, + }); + assert.equal(snapshot.status, "error"); + assert.isTrue(snapshot.installed); + assert.equal(snapshot.message, "Kiro CLI is installed but failed to run."); + // A broken CLI is not asked for its sign-in. + assert.deepEqual(invoked, ["--version"]); + }), + ); + + // `whoami` outputs recorded from Kiro CLI 2.27.0. + it.effect("asks for kiro-cli login when Kiro is signed out", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"account":null}\n', code: 1 }, + }); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.version, "2.27.0"); + assert.equal(snapshot.auth.status, "unauthenticated"); + assert.include(snapshot.message ?? "", "kiro-cli login"); + }), + ); + + it.effect("is ready once Kiro reports a signed-in account", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"ApiKey","email":null}\n', code: 0 }, + }); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + // The version advisory is what the compatibility check and update UI read. + assert.equal(snapshot.versionAdvisory?.currentVersion, "2.27.0"); + assert.isFalse(snapshot.versionAdvisory?.canUpdate); + }), + ); + + // Shape of `kiro-cli chat --list-models --format json` on Kiro CLI 2.27.0, trimmed to three models. + const KIRO_MODEL_LIST = JSON.stringify({ + models: [ + { model_name: "auto", model_id: "auto", description: "Models chosen by task" }, + { model_name: "claude-sonnet-4.5", model_id: "claude-sonnet-4.5" }, + { model_name: "claude-haiku-4.5", model_id: "claude-haiku-4.5" }, + ], + default_model: "auto", + }); + + it.effect("lists the signed-in account's models with auto as the Kiro default", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + "chat --list-models --format json": { stdout: `${KIRO_MODEL_LIST}\n`, code: 0 }, + }); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.name, model.isDefault === true]), + [ + ["default", "auto", true], + ["claude-sonnet-4.5", "claude-sonnet-4.5", false], + ["claude-haiku-4.5", "claude-haiku-4.5", false], + ], + ); + }), + ); + + it.effect("names Kiro default after the model Kiro's list names as its default", () => + Effect.gen(function* () { + const signedIn = { + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + }; + const listed = yield* check({ + ...signedIn, + "chat --list-models --format json": { + stdout: KIRO_MODEL_LIST.replace( + '"default_model":"auto"', + '"default_model":"claude-sonnet-4.5"', + ), + code: 0, + }, + }); + assert.deepEqual( + listed.snapshot.models.map((model) => [model.slug, model.name]), + [ + ["auto", "auto"], + ["default", "claude-sonnet-4.5"], + ["claude-haiku-4.5", "claude-haiku-4.5"], + ], + ); + assert.equal(kiroDefaultModelId(listed.snapshot.models), "claude-sonnet-4.5"); + // An unparsable list keeps the built-in entry, which falls back to `auto`. + const unlisted = yield* check({ + ...signedIn, + "chat --list-models --format json": { stdout: "not json\n", code: 0 }, + }); + assert.equal(unlisted.snapshot.models[0]?.name, "Kiro default"); + assert.equal(kiroDefaultModelId(unlisted.snapshot.models), "auto"); + }), + ); + + it.effect("lists Kiro default even when Kiro's model list leaves its default out", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + "chat --list-models --format json": { + stdout: JSON.stringify({ models: [{ model_id: "claude-sonnet-4.5" }] }), + code: 0, + }, + }); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.name, model.isDefault === true]), + [ + ["default", "Kiro default", true], + ["claude-sonnet-4.5", "claude-sonnet-4.5", false], + ], + ); + assert.equal(kiroDefaultModelId(snapshot.models), "auto"); + }), + ); + + // `--list-models` names no effort levels, so the picker's come from Kiro's ids. + const KIRO_EFFORT_MODEL_LIST = JSON.stringify({ + models: [ + "auto", + "claude-fable-5.1", + "claude-opus-5.5", + "claude-opus-4.6", + "kimi-k3", + "claude-sonnet-4.5", + ].map((id) => ({ + model_name: id, + model_id: id, + })), + default_model: "auto", + }); + + it.effect("offers each model only the reasoning levels Kiro takes for it", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + "chat --list-models --format json": { stdout: KIRO_EFFORT_MODEL_LIST, code: 0 }, + }); + const reasoning = snapshot.models.map((model) => { + const descriptors = model.capabilities?.optionDescriptors ?? []; + const select = descriptors.find((descriptor) => descriptor.id === "reasoningEffort"); + return select?.type === "select" + ? { + slug: model.slug, + label: select.label, + levels: select.options.map((choice) => choice.id), + defaults: select.options.filter((choice) => choice.isDefault).map((c) => c.id), + currentValue: select.currentValue, + } + : { slug: model.slug, descriptors: descriptors.length }; + }); + assert.deepEqual(reasoning, [ + // Kiro default and models without effort get no Reasoning select. + { slug: "default", descriptors: 0 }, + { + slug: "claude-fable-5.1", + label: "Reasoning", + levels: ["low", "medium", "high", "xhigh", "max"], + defaults: ["high"], + currentValue: "high", + }, + { + slug: "claude-opus-5.5", + label: "Reasoning", + levels: ["low", "medium", "high", "xhigh", "max"], + defaults: ["medium"], + currentValue: "medium", + }, + // Opus 4.6 has no xhigh, and Kiro runs it at high by default. + { + slug: "claude-opus-4.6", + label: "Reasoning", + levels: ["low", "medium", "high", "max"], + defaults: ["high"], + currentValue: "high", + }, + // Kimi K3 skips medium and runs at max by default. + { + slug: "kimi-k3", + label: "Reasoning", + levels: ["low", "high", "max"], + defaults: ["max"], + currentValue: "max", + }, + { slug: "claude-sonnet-4.5", descriptors: 0 }, + ]); + }), + ); + + it.effect("gives every model, custom ones too, the agent picker Kiro's template lists", () => + Effect.gen(function* () { + const { snapshot } = yield* check( + { + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + "chat --list-models --format json": { stdout: KIRO_MODEL_LIST, code: 0 }, + }, + { + template: { + configOptions: [ + { + type: "select", + id: "mode", + name: "Mode", + currentValue: "vibe", + options: [ + { value: "vibe", name: "Default" }, + { value: "reviewer", name: "reviewer" }, + ], + }, + ], + }, + settings: { ...settings, customModels: ["my-model"] }, + }, + ); + assert.deepEqual( + snapshot.models.map((model) => [ + model.slug, + model.capabilities?.optionDescriptors?.flatMap((descriptor) => + descriptor.type === "select" && descriptor.id === "mode" + ? descriptor.options.map((choice) => choice.id) + : [], + ), + ]), + [ + ["default", ["vibe", "reviewer"]], + ["claude-sonnet-4.5", ["vibe", "reviewer"]], + ["claude-haiku-4.5", ["vibe", "reviewer"]], + ["my-model", ["vibe", "reviewer"]], + ], + ); + }), + ); + + it("offers Kiro's agents from its config template, Default first", () => { + // `_kiro/config/template` from kiro-cli 2.27.0 with two user agents in + // `~/.kiro/agents`, trimmed to three bundled agents and the `mode` option. + const template = { + modes: { currentModeId: "vibe", availableModes: [] }, + configOptions: [ + { + type: "select", + id: "mode", + name: "Mode", + category: "mode", + currentValue: "vibe", + options: [ + { value: "vibe", name: "Default", description: "General coding assistance" }, + { value: "spec", name: "Spec", description: "Structured feature development" }, + { value: "plan", name: "Plan", description: "Plan-only mode" }, + { value: "global-probe", name: "global-probe", description: "Global probe agent" }, + { value: "planner", name: "planner", description: null }, + ], + }, + { + type: "select", + id: "autopilot", + name: "Autopilot", + currentValue: "on", + options: [{ value: "on", name: "Autopilot" }], + }, + ], + }; + const descriptor = kiroAgentDescriptor(template); + assert.deepEqual(descriptor, { + id: "mode", + label: "Agent", + type: "select", + currentValue: "vibe", + options: [ + { id: "vibe", label: "Default", description: "General coding assistance", isDefault: true }, + { id: "spec", label: "Spec", description: "Structured feature development" }, + { id: "plan", label: "Plan", description: "Plan-only mode" }, + { id: "global-probe", label: "global-probe", description: "Global probe agent" }, + { id: "planner", label: "planner" }, + ], + }); + // The default follows the agent Kiro starts sessions on. + const onSpec = kiroAgentDescriptor({ + configOptions: [{ ...template.configOptions[0], currentValue: "spec" }], + }); + assert.equal(onSpec?.type === "select" ? onSpec.currentValue : undefined, "spec"); + assert.deepEqual( + onSpec?.type === "select" ? onSpec.options.filter((choice) => choice.isDefault) : [], + [ + { + id: "spec", + label: "Spec", + description: "Structured feature development", + isDefault: true, + }, + ], + ); + // A template without agents to choose between gives no picker. + assert.isUndefined(kiroAgentDescriptor({ configOptions: [] })); + assert.isUndefined(kiroAgentDescriptor("not a template")); + }); + + it.effect("never lists models while signed out, where listing would start a browser login", () => + Effect.gen(function* () { + const { snapshot, invoked } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"account":null}\n', code: 1 }, + "chat --list-models --format json": { stdout: `${KIRO_MODEL_LIST}\n`, code: 0 }, + }); + assert.notInclude(invoked, "chat --list-models --format json"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default"], + ); + }), + ); +}); diff --git a/apps/server/src/provider/ProviderAccountTransfer.test.ts b/apps/server/src/provider/ProviderAccountTransfer.test.ts index fdde00d93076b..6c55bf2cec6c9 100644 --- a/apps/server/src/provider/ProviderAccountTransfer.test.ts +++ b/apps/server/src/provider/ProviderAccountTransfer.test.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/contracts"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { fxClient } from "@t3tools/provider-fx/client"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -615,7 +616,7 @@ describe("ProviderAccountTransfer on the real settings layer", () => { readStaticCredential: () => Effect.succeedNone, }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), diff --git a/apps/server/src/provider/ProviderCredentialStore.ts b/apps/server/src/provider/ProviderCredentialStore.ts index 631b31bc3faa7..51557a3be0d90 100644 --- a/apps/server/src/provider/ProviderCredentialStore.ts +++ b/apps/server/src/provider/ProviderCredentialStore.ts @@ -1,4 +1,4 @@ -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -8,10 +8,10 @@ export const make = Effect.fn("ProviderCredentialStore.make")(function* ( bindingId: string, ) { const secrets = yield* ServerSecretStore.ServerSecretStore; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; // Hash the tuple so arbitrary bindings cannot escape or exceed a filename. - const bindingHash = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`${driver.length}:${driver}${bindingId}`)) + const bindingHash = yield* digest + .digest(Digest.sha256, new TextEncoder().encode(`${driver.length}:${driver}${bindingId}`)) .pipe(Effect.map(Hex.encode), Effect.orDie); const key = `provider-auth-${bindingHash}`; return { diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index fa10b871d8473..3f14ecc7a8e6b 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -2,7 +2,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; -import * as Crypto from "effect/Crypto"; +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"; @@ -24,7 +24,7 @@ export const layer = Layer.effect( const serverSettings = yield* ServerSettings.ServerSettingsService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const secrets = yield* ServerSecretStore.ServerSecretStore; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcess.Platform; @@ -57,7 +57,7 @@ export const layer = Layer.effect( ), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), Effect.provideService(HostProcess.Platform, platform), Effect.catch(() => Effect.logWarning("Could not prepare the managed GitHub CLI launcher.").pipe( @@ -97,7 +97,7 @@ export const layer = Layer.effect( ), })), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), ); }), }); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index e8ac94c7ceb28..98d8a76a991d5 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -5,6 +5,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId, @@ -171,7 +172,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { endpoint: Effect.succeed("unused-private-pipe"), }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge( Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "unused-managed-installation", diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 57a218a0dafe1..cc3ca60fcb829 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -9,6 +9,8 @@ import { type SecretReference, secretReferencesInSettings, type ServerProvider, + type SettingsSecretSlot, + withSettingsSecrets, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -45,7 +47,7 @@ export class ProviderInstanceRegistry extends Context.Service< interface InstanceSecrets { readonly environment: ResolvedProviderInstanceEnvironment; - readonly config: Readonly>; + readonly config: ReadonlyArray; } interface LiveEntry { @@ -110,12 +112,12 @@ const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecr } return [{ ...variable, value: secret, sensitive: true }]; }); - const config: Record = {}; - for (const { field } of configFields) { + const config = configFields.flatMap(({ field, key }) => { const secret = values[index++]; - if (secret === undefined) unreadable.push(field); - else config[field] = secret; - } + if (secret !== undefined) return [{ field, key, value: secret }]; + unreadable.push(field); + return []; + }); const secrets: InstanceSecrets = { environment, config }; return { secrets, unreadable }; }); @@ -224,7 +226,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (refreshedProvider); + const currentProvider = yield* Ref.make(pendingProvider); const instance = { instanceId: cachedProvider.instanceId, driverKind: cachedProvider.driver, @@ -1162,8 +1166,10 @@ it.layer( packageName: null, }), ), - getSnapshot: Effect.succeed(pendingProvider), - refresh: Ref.get(nextProvider), + getSnapshot: Ref.get(currentProvider), + refresh: Ref.get(nextProvider).pipe( + Effect.tap((provider) => Ref.set(currentProvider, provider)), + ), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, markLimited: () => Effect.void, @@ -1185,6 +1191,7 @@ it.layer( const retainedModels = [defaultModel, customModel, currentModel]; for (const restarted of [false, true]) { + yield* Ref.set(currentProvider, pendingProvider); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual( @@ -1221,7 +1228,7 @@ it.layer( Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-pi-model-cache-", - }).pipe(Layer.provideMerge(NodeServices.layer)), + }).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ), ); @@ -1281,6 +1288,7 @@ it.layer( }); yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); const nextProvider = yield* Ref.make(refreshedProvider); + const currentProvider = yield* Ref.make(pendingProvider); const instance = { instanceId: cachedProvider.instanceId, driverKind: cachedProvider.driver, @@ -1298,8 +1306,10 @@ it.layer( packageName: null, }), ), - getSnapshot: Effect.succeed(pendingProvider), - refresh: Ref.get(nextProvider), + getSnapshot: Ref.get(currentProvider), + refresh: Ref.get(nextProvider).pipe( + Effect.tap((provider) => Ref.set(currentProvider, provider)), + ), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, markLimited: () => Effect.void, @@ -1324,6 +1334,7 @@ it.layer( ]; for (const restarted of [false, true]) { + yield* Ref.set(currentProvider, pendingProvider); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; const expectedModels = restarted @@ -1358,7 +1369,7 @@ it.layer( Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-retired-model-cache-", - }).pipe(Layer.provideMerge(NodeServices.layer)), + }).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ), ); @@ -1557,6 +1568,224 @@ it.layer( ); }); + it.effect("invalidates model caches only for explicit model or fresh workspace refreshes", () => + Effect.gen(function* () { + const forceRefreshCalls = yield* Ref.make(0); + const makeInstance = (name: string) => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make(name); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const invalidations = yield* Ref.make(0); + const workspaceScans = yield* Ref.make(0); + const maintenanceReads = yield* Ref.make>([]); + const instance = { + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: `codex:instance:${name}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: (options) => + Ref.update(maintenanceReads, (reads) => [...reads, options?.fresh === true]).pipe( + Effect.as( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: `${name}-package`, + }), + ), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + markLimited: () => Effect.void, + }, + invalidateCaches: Ref.update(invalidations, (count) => count + 1), + snapshotForCwd: (cwd) => + Ref.update(workspaceScans, (count) => count + 1).pipe( + Effect.as({ + ...provider, + skills: [{ name, path: `${cwd}/${name}/SKILL.md`, enabled: true }], + }), + ), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const takeMaintenanceReads = Ref.getAndSet(maintenanceReads, []); + return { instance, invalidations, workspaceScans, takeMaintenanceReads }; + }); + const first = yield* makeInstance("codex_first"); + const second = yield* makeInstance("codex_second"); + const unrelated = yield* makeInstance("codex_unrelated"); + const instances = [first.instance, second.instance, unrelated.instance]; + const poolInstanceId = providerPoolInstanceId(ProviderDriverKind.make("codex")); + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const layerModelManifest = Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + forceRefresh: Ref.update(forceRefreshCalls, (count) => count + 1).pipe( + Effect.as(ModelManifest.BUNDLED_MODEL_MANIFEST), + ), + refreshInBackground: Effect.void, + }); + const layerLatestVersions = Layer.effect( + ProviderLatestVersions.ProviderLatestVersions, + ProviderLatestVersions.make([ + ["codex_first-package", "1.0.0"], + ["codex_second-package", "1.0.0"], + ]), + ); + const readCachedVersion = (key: string) => + ProviderLatestVersions.ProviderLatestVersions.use((latestVersions) => + latestVersions.cached(key, Effect.succeed("2.0.0")), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + + yield* registry.refreshInstance(first.instance.instanceId); + yield* registry.refresh(); + assert.strictEqual(yield* Ref.get(forceRefreshCalls), 0); + assert.strictEqual(yield* Ref.get(first.invalidations), 0); + assert.strictEqual(yield* readCachedVersion("codex_first-package"), "1.0.0"); + yield* first.takeMaintenanceReads; + + yield* registry.invalidateModelCaches({ instanceId: first.instance.instanceId }); + // The instance refresh that follows resolves installer metadata fresh itself. + assert.deepStrictEqual(yield* first.takeMaintenanceReads, [false]); + assert.strictEqual(yield* Ref.get(forceRefreshCalls), 1); + assert.strictEqual(yield* Ref.get(first.invalidations), 1); + assert.strictEqual(yield* Ref.get(second.invalidations), 0); + assert.strictEqual(yield* readCachedVersion("codex_first-package"), "2.0.0"); + assert.strictEqual(yield* readCachedVersion("codex_second-package"), "1.0.0"); + + yield* registry.invalidateModelCaches({}); + assert.strictEqual(yield* Ref.get(first.invalidations), 2); + assert.strictEqual(yield* Ref.get(second.invalidations), 1); + assert.strictEqual(yield* readCachedVersion("codex_second-package"), "2.0.0"); + yield* first.takeMaintenanceReads; + + // Only a cached workspace refresh leaves the fresh resolution to this call. + const workspace = { instanceId: first.instance.instanceId, cwd: process.cwd() }; + yield* registry.invalidateModelCaches(workspace); + assert.deepStrictEqual(yield* first.takeMaintenanceReads, [true]); + yield* registry.invalidateModelCaches({ ...workspace, fresh: true }); + assert.deepStrictEqual(yield* first.takeMaintenanceReads, [false]); + + yield* registry.invalidateModelCaches({ + instanceId: poolInstanceId, + }); + assert.strictEqual(yield* Ref.get(first.invalidations), 5); + assert.strictEqual(yield* Ref.get(second.invalidations), 2); + assert.strictEqual(yield* Ref.get(unrelated.invalidations), 1); + assert.deepStrictEqual(yield* first.takeMaintenanceReads, [false]); + + const poolWorkspace = { instanceId: poolInstanceId, cwd: "/workspace" }; + yield* registry.refreshWorkspaceSnapshot(poolWorkspace); + yield* registry.refreshWorkspaceSnapshot({ + instanceId: unrelated.instance.instanceId, + cwd: poolWorkspace.cwd, + }); + yield* registry.refreshWorkspaceSnapshot({ + instanceId: unrelated.instance.instanceId, + cwd: "/other-workspace", + }); + // A cached pool request retains each member's workspace scan and cache timer. + yield* registry.refreshWorkspaceSnapshot(poolWorkspace); + assert.strictEqual(yield* Ref.get(first.workspaceScans), 1); + assert.strictEqual(yield* Ref.get(second.workspaceScans), 1); + assert.strictEqual(yield* Ref.get(first.invalidations), 5); + assert.strictEqual(yield* Ref.get(second.invalidations), 2); + + yield* registry.refreshWorkspaceSnapshot({ ...poolWorkspace, fresh: true }); + assert.strictEqual(yield* Ref.get(first.invalidations), 6); + assert.strictEqual(yield* Ref.get(second.invalidations), 3); + assert.strictEqual(yield* Ref.get(unrelated.invalidations), 1); + assert.strictEqual(yield* Ref.get(first.workspaceScans), 2); + assert.strictEqual(yield* Ref.get(second.workspaceScans), 2); + assert.strictEqual(yield* Ref.get(unrelated.workspaceScans), 2); + const workspacesByInstance = registry.getProviders.pipe( + Effect.map((providers) => + Object.fromEntries( + providers.map((provider) => [ + provider.instanceId, + provider.workspaceSnapshots?.map((snapshot) => snapshot.cwd) ?? [], + ]), + ), + ), + ); + assert.deepStrictEqual(yield* workspacesByInstance, { + [first.instance.instanceId]: [poolWorkspace.cwd], + [second.instance.instanceId]: [poolWorkspace.cwd], + [unrelated.instance.instanceId]: ["/other-workspace"], + }); + + // Selecting a single account still invalidates other accounts for only that cwd. + yield* registry.refreshWorkspaceSnapshot({ + ...poolWorkspace, + instanceId: first.instance.instanceId, + fresh: true, + }); + assert.deepStrictEqual(yield* workspacesByInstance, { + [first.instance.instanceId]: [poolWorkspace.cwd], + [second.instance.instanceId]: [], + [unrelated.instance.instanceId]: ["/other-workspace"], + }); + }).pipe( + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge(layerModelManifest), + Layer.provideMerge(layerLatestVersions), + Layer.provide( + ServerSettingsModule.layerTest({ + providerPools: { + [ProviderDriverKind.make("codex")]: { + enabled: true, + memberInstanceIds: [first.instance.instanceId, second.instance.instanceId], + }, + }, + }), + ), + Layer.provide(layerUnusedMutator), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-model-cache-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ); + it.effect( "a second request for a cwd being scanned gets its result, even if the first leaves", () => @@ -1630,7 +1859,7 @@ it.layer( prefix: "t3-provider-registry-shared-workspace-scan-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -1727,7 +1956,7 @@ it.layer( prefix: "t3-provider-registry-unhealthy-workspace-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -1849,7 +2078,7 @@ it.layer( prefix: "t3-provider-registry-shared-refresh-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -1871,6 +2100,134 @@ it.layer( }), ); + it.effect("keeps an enriched snapshot that streams in before a manual refresh returns", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("acpRegistry"); + const instanceId = ProviderInstanceId.make("gemini"); + const initialProvider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...initialProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-06-10T00:01:00.000Z", + message: "Checking ACP authentication, models, and commands in the background...", + } as const satisfies ServerProvider; + const enrichedProvider = { + ...initialProvider, + status: "error", + auth: { status: "unauthenticated" }, + checkedAt: "2026-06-10T00:01:01.000Z", + message: "Sign in to Gemini to use this agent.", + } as const satisfies ServerProvider; + const snapshotRef = yield* Ref.make(initialProvider); + const changes = yield* PubSub.unbounded(); + const publishSnapshot = (provider: ServerProvider) => + Ref.set(snapshotRef, provider).pipe(Effect.andThen(PubSub.publish(changes, provider))); + const releaseRefresh = yield* Deferred.make(); + const instance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "acpRegistry:gemini" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Ref.get(snapshotRef), + // Like a managed provider: publish the probe, let enrichment publish its result, + // then return the probe after a slow tail such as ACP's refreshMethods. + refresh: Effect.gen(function* () { + yield* publishSnapshot(pendingProvider); + yield* publishSnapshot(enrichedProvider); + yield* Deferred.await(releaseRefresh); + return pendingProvider; + }), + streamChanges: Stream.fromPubSub(changes), + applyUsageLimits: () => Effect.void, + markLimited: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-enriched-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId, + }); + expect(yield* registry.getProviders).toEqual([expect.objectContaining(initialProvider)]); + + const enrichedObserved = yield* awaitPersistedProvider( + registry, + enrichedProvider.checkedAt, + ); + const refreshed = yield* registry.refreshInstance(instanceId).pipe(Effect.forkChild); + yield* Fiber.join(enrichedObserved); + yield* Deferred.succeed(releaseRefresh, undefined); + const returned = yield* Fiber.join(refreshed); + + const enriched = { + status: enrichedProvider.status, + auth: enrichedProvider.auth, + message: enrichedProvider.message, + checkedAt: enrichedProvider.checkedAt, + }; + expect( + yield* registry.getProviders, + "getProviders should hold the enriched snapshot, not the stale pending one refresh returned", + ).toEqual([expect.objectContaining(enriched)]); + expect(returned).toEqual([expect.objectContaining(enriched)]); + expect(yield* readProviderStatusCache(filePath)).toEqual( + expect.objectContaining(enriched), + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); @@ -1958,7 +2315,7 @@ it.layer( }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -1989,6 +2346,168 @@ it.layer( }), ); + it.effect("streams the terminal update state when it lands during a slow cache write", () => + Effect.gen(function* () { + const claudeDriver = ProviderDriverKind.make("claude"); + const claudeInstanceId = ProviderInstanceId.make("claude"); + const initialProvider = { + instanceId: claudeInstanceId, + driver: claudeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2.1.300", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const verifyingProvider = { ...initialProvider, checkedAt: "2026-04-14T00:01:00.000Z" }; + const laterProvider = { ...initialProvider, checkedAt: "2026-04-14T00:02:00.000Z" }; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: claudeInstanceId, + driverKind: claudeDriver, + continuationIdentity: { + driverKind: claudeDriver, + continuationKey: "claude:instance:claude", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: claudeDriver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(initialProvider), + streamChanges: Stream.fromPubSub(changes), + applyUsageLimits: () => Effect.void, + markLimited: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === claudeInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const writeStarted = yield* Deferred.make(); + const releaseWrite = yield* Deferred.make(); + const gatedCachePath = yield* Ref.make(undefined); + const layerGatedFileSystem = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + ...fileSystem, + rename: (from, to) => + Ref.modify(gatedCachePath, (path) => + path === to ? [true, undefined] : [false, path], + ).pipe( + Effect.flatMap((gated) => + gated + ? Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseWrite)), + ) + : Effect.void, + ), + Effect.andThen(fileSystem.rename(from, to)), + ), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeServices.layer)); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-update-state-race-", + }), + ), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + Layer.provideMerge(layerGatedFileSystem), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const running = { + status: "running", + startedAt: "2026-04-14T00:00:30.000Z", + finishedAt: null, + message: "Verifying the installed version", + output: null, + } as const; + const unchanged = { + ...running, + status: "unchanged", + finishedAt: "2026-04-14T00:01:30.000Z", + message: + "Update command completed, but T3 Code still detects an outdated provider version.", + } as const; + yield* registry.setProviderMaintenanceActionState({ + instanceId: claudeInstanceId, + action: "update", + state: running, + }); + const streamed = yield* registry.streamChanges.pipe( + Stream.takeUntil((providers) => + providers.some((provider) => provider.checkedAt === laterProvider.checkedAt), + ), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ); + + yield* Ref.set( + gatedCachePath, + yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: claudeInstanceId, + }), + ); + yield* PubSub.publish(changes, verifyingProvider); + yield* Deferred.await(writeStarted); + yield* registry.setProviderMaintenanceActionState({ + instanceId: claudeInstanceId, + action: "update", + state: unchanged, + }); + yield* Deferred.succeed(releaseWrite, undefined); + // The snapshot stream is consumed in order, so this lands after the slow write publishes. + yield* PubSub.publish(changes, laterProvider); + + const updateStateOf = (providers: ReadonlyArray | undefined) => + providers?.find((provider) => provider.instanceId === claudeInstanceId)?.updateState; + const beforeLater = (yield* Fiber.join(streamed)).slice(0, -1); + assert.deepStrictEqual(updateStateOf(yield* registry.getProviders), unchanged); + assert.deepStrictEqual( + updateStateOf(beforeLater.at(-1)), + unchanged, + "the last streamed providers before the next snapshot should carry the terminal update state", + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect( "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", () => @@ -2087,7 +2606,7 @@ it.layer( prefix: "t3-provider-registry-opencode-authoritative-persist-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -2193,7 +2712,7 @@ it.layer( }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -2286,7 +2805,7 @@ it.layer( return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command))); }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), ); const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( @@ -2391,7 +2910,7 @@ it.layer( Layer.provideMerge( OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), ); const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 84ff5165297b4..797f99b3538ec 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -3,6 +3,7 @@ import { isProviderWorkspaceSnapshotCurrent, ProviderDriverKind, providerPoolDriver, + resolveActiveProviderPools, type IsoDateTime, type ProviderInstanceId, type ServerProvider, @@ -100,6 +101,17 @@ export class ProviderRegistry extends Context.Service< readonly fresh?: boolean; }) => Effect.Effect>; + /** + * Drop T3-owned model caches before an explicit model refresh. Pass the + * target of the refresh that follows. Background and ordinary refreshes + * keep their cache timers and must not call this. + */ + readonly invalidateModelCaches: (input: { + readonly instanceId?: ProviderInstanceId | undefined; + readonly cwd?: string | undefined; + readonly fresh?: boolean | undefined; + }) => Effect.Effect; + readonly getProviderMaintenanceCapabilitiesForInstance: ( instanceId: ProviderInstanceId, provider: ProviderDriverKind, @@ -564,7 +576,8 @@ export const layer = Layer.effect( }); } if (options?.publish !== false) { - yield* PubSub.publish(changesPubSub, providers); + // A newer upsert may have committed and published during the cache write; never republish an older array. + yield* PubSub.publish(changesPubSub, yield* Ref.get(providersRef)); } } @@ -632,7 +645,9 @@ export const layer = Layer.effect( const refreshOneSource = Effect.fn("refreshOneSource")(function* ( providerSource: ProviderSnapshotSource, ) { + // Enrichment can publish a newer snapshot before refresh returns its pre-enrichment probe. return yield* providerSource.refresh.pipe( + Effect.andThen(providerSource.getSnapshot), Effect.flatMap((nextProvider) => correlateSnapshotWithSource(providerSource, nextProvider).pipe( Effect.flatMap(syncProvider), @@ -974,111 +989,135 @@ export const layer = Layer.effect( Effect.map(([, nextProviders]) => nextProviders), ); - const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { - readonly instanceId: ProviderInstanceId; - readonly cwd: string; - readonly fresh?: boolean; - }) { + const refreshInstanceWorkspaceSnapshot = Effect.fn("refreshInstanceWorkspaceSnapshot")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + readonly fresh?: boolean; + }) { + const providers = yield* Ref.get(providersRef); + const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); + const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => + candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); + const scannedFrom = workspaceSnapshotOf(provider); + const now = yield* DateTime.now; + if ( + !provider || + !provider.enabled || + (!input.fresh && + scannedFrom && + !scannedFrom.slashCommandsPending && + isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now))) + ) { + return providers; + } + // Use scan start for TTL, not checkedAt inherited from the machine health check. + const scannedAt = DateTime.formatIso(now); + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (!instance?.snapshotForCwd) return providers; + const token = yield* Deferred.make(); + const refreshMachineSnapshot = input.fresh + ? (instance.invalidateCaches ?? Effect.void).pipe( + Effect.andThen(refreshInstance(input.instanceId)), + ) + : Effect.void; + const runScan = refreshMachineSnapshot.pipe( + Effect.andThen(instance.snapshotForCwd(input.cwd)), + // Scans fail on error; returned inventory remains valid even when provider health reports an error. + Effect.flatMap((scopedSnapshot) => + instanceRegistry.getInstance(input.instanceId).pipe( + Effect.flatMap((currentInstance) => { + if (currentInstance !== instance) return Ref.get(providersRef); + return updateProviders((currentProviders) => { + // Read ownership inside the update so a newer claim cannot race publication. + if ( + Ref.getUnsafe(workspaceRefreshesRef).get(instance)?.get(input.cwd) !== token + ) { + return currentProviders; + } + return currentProviders.map((candidate) => + candidate.instanceId === input.instanceId && + Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) + ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { + ...scopedSnapshot, + checkedAt: scannedAt, + // Without driver command discovery, retain previous commands after failed health checks and retry. + slashCommandsPending: + scopedSnapshot.slashCommandsPending ?? + scopedSnapshot.status === "error", + }) + : candidate, + ); + }); + }), + ), + ), + Effect.ensuring( + Ref.update(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + if (current?.get(input.cwd) !== token) return refreshes; + const next = new Map(refreshes); + const nextCwds = new Map(current); + nextCwds.delete(input.cwd); + if (nextCwds.size) next.set(instance, nextCwds); + else next.delete(instance); + return next; + }).pipe(Effect.andThen(Deferred.succeed(token, undefined))), + ), + ); + // The claim and the fork happen together, so the token is always completed. The scan is + // detached so a disconnecting owner cannot cancel the result its waiters depend on. + const claim = yield* Effect.uninterruptible( + Effect.gen(function* () { + const inFlight = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + const running = current?.get(input.cwd); + if (!input.fresh && running) return [running, refreshes] as const; + const next = new Map(refreshes); + next.set(instance, new Map(current).set(input.cwd, token)); + return [undefined, next] as const; + }); + if (inFlight) return { inFlight } as const; + return { + // Starting now attaches the scan's `ensuring` before anything can close the scope. + scan: yield* Effect.forkIn(runScan, serviceScope, { startImmediately: true }), + } as const; + }), + ); + // Wait for the running scan: on-demand clients get its result only in this reply. + if ("inFlight" in claim) { + return yield* Deferred.await(claim.inFlight).pipe(Effect.andThen(Ref.get(providersRef))); + } + return yield* Fiber.join(claim.scan); + }, + ); + + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* ( + input: Parameters[0], + ) { + const instanceIds = + providerPoolDriver(input.instanceId) === null + ? [input.instanceId] + : (resolveActiveProviderPools({ + providerPools: yield* providerPools, + instances: yield* Ref.get(providersRef), + }).find((pool) => pool.instanceId === input.instanceId)?.memberInstanceIds ?? []); if (input.fresh) { + // Clear other providers once: each member scan must retain the other pool members' results. yield* updateProviders((providers) => providers.map((candidate) => - candidate.instanceId === input.instanceId + instanceIds.includes(candidate.instanceId) ? candidate : dropProviderWorkspaceSnapshot(candidate, input.cwd), ), ); } - const providers = yield* Ref.get(providersRef); - const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); - const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => - candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); - const scannedFrom = workspaceSnapshotOf(provider); - const now = yield* DateTime.now; - if ( - !provider || - !provider.enabled || - (!input.fresh && - scannedFrom && - !scannedFrom.slashCommandsPending && - isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now))) - ) { - return providers; - } - // Use scan start for TTL, not checkedAt inherited from the machine health check. - const scannedAt = DateTime.formatIso(now); - const instance = yield* instanceRegistry.getInstance(input.instanceId); - if (!instance?.snapshotForCwd) return providers; - const token = yield* Deferred.make(); - const refreshMachineSnapshot = input.fresh - ? (instance.invalidateCaches ?? Effect.void).pipe( - Effect.andThen(refreshInstance(input.instanceId)), - ) - : Effect.void; - const runScan = refreshMachineSnapshot.pipe( - Effect.andThen(instance.snapshotForCwd(input.cwd)), - // Scans fail on error; returned inventory remains valid even when provider health reports an error. - Effect.flatMap((scopedSnapshot) => - instanceRegistry.getInstance(input.instanceId).pipe( - Effect.flatMap((currentInstance) => { - if (currentInstance !== instance) return Ref.get(providersRef); - return updateProviders((currentProviders) => { - // Read ownership inside the update so a newer claim cannot race publication. - if (Ref.getUnsafe(workspaceRefreshesRef).get(instance)?.get(input.cwd) !== token) { - return currentProviders; - } - return currentProviders.map((candidate) => - candidate.instanceId === input.instanceId && - Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) - ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { - ...scopedSnapshot, - checkedAt: scannedAt, - // Without driver command discovery, retain previous commands after failed health checks and retry. - slashCommandsPending: - scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", - }) - : candidate, - ); - }); - }), - ), - ), - Effect.ensuring( - Ref.update(workspaceRefreshesRef, (refreshes) => { - const current = refreshes.get(instance); - if (current?.get(input.cwd) !== token) return refreshes; - const next = new Map(refreshes); - const nextCwds = new Map(current); - nextCwds.delete(input.cwd); - if (nextCwds.size) next.set(instance, nextCwds); - else next.delete(instance); - return next; - }).pipe(Effect.andThen(Deferred.succeed(token, undefined))), - ), - ); - // The claim and the fork happen together, so the token is always completed. The scan is - // detached so a disconnecting owner cannot cancel the result its waiters depend on. - const claim = yield* Effect.uninterruptible( - Effect.gen(function* () { - const inFlight = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { - const current = refreshes.get(instance); - const running = current?.get(input.cwd); - if (!input.fresh && running) return [running, refreshes] as const; - const next = new Map(refreshes); - next.set(instance, new Map(current).set(input.cwd, token)); - return [undefined, next] as const; - }); - if (inFlight) return { inFlight } as const; - return { - // Starting now attaches the scan's `ensuring` before anything can close the scope. - scan: yield* Effect.forkIn(runScan, serviceScope, { startImmediately: true }), - } as const; - }), + yield* Effect.forEach( + instanceIds, + (instanceId) => refreshInstanceWorkspaceSnapshot({ ...input, instanceId }), + { concurrency: "unbounded", discard: true }, ); - // Wait for the running scan: on-demand clients get its result only in this reply. - if ("inFlight" in claim) { - return yield* Deferred.await(claim.inFlight).pipe(Effect.andThen(Ref.get(providersRef))); - } - return yield* Fiber.join(claim.scan); + return yield* Ref.get(providersRef); }); const getPublishedProviders = Effect.all([Ref.get(providersRef), providerPools]).pipe( @@ -1103,6 +1142,47 @@ export const layer = Layer.effect( } }); + const invalidateModelCaches = Effect.fn("ProviderRegistry.invalidateModelCaches")(function* ( + input: Parameters[0], + ) { + yield* manifestService.forceRefresh; + const instances = yield* instanceRegistry.listInstances; + const instanceIds = + input.instanceId === undefined + ? undefined + : providerPoolDriver(input.instanceId) === null + ? [input.instanceId] + : (resolveActiveProviderPools({ + providerPools: yield* providerPools, + instances: instances.map((instance) => ({ + instanceId: instance.instanceId, + driver: instance.driverKind, + enabled: instance.enabled, + })), + }).find((pool) => pool.instanceId === input.instanceId)?.memberInstanceIds ?? []); + yield* Effect.forEach( + instances.filter( + (instance) => instanceIds === undefined || instanceIds.includes(instance.instanceId), + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + // Instance and full refreshes resolve installer metadata fresh + // themselves; a cached workspace refresh needs it resolved here. + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: + input.instanceId !== undefined && input.cwd !== undefined && input.fresh !== true, + }); + yield* Effect.forEach( + latestProviderVersionCacheKeys(maintenance), + latestVersions.invalidate, + { discard: true }, + ); + }), + { concurrency: "unbounded", discard: true }, + ); + }); + return { getProviders: Ref.get(providersRef), getPublishedProviders, @@ -1117,6 +1197,7 @@ export const layer = Layer.effect( refreshWorkspaceSnapshot: (input) => refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), refreshAfterUpdate, + invalidateModelCaches, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/TurnTokenUsage.test.ts b/apps/server/src/provider/TurnTokenUsage.test.ts index 06802143e73c1..fb14511211e36 100644 --- a/apps/server/src/provider/TurnTokenUsage.test.ts +++ b/apps/server/src/provider/TurnTokenUsage.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { normalizeClaudeTurnTokenUsage } from "./ClaudeTurnTokenUsage.ts"; +import { + claudeLedgerDelta, + claudeModelLedger, + normalizeClaudeTurnTokenUsage, +} from "./ClaudeTurnTokenUsage.ts"; import { accumulateCodexTurnTokenUsage, completeCodexTurnTokenUsage, @@ -209,6 +213,15 @@ describe("billable native turn usage", () => { }, ); + it("measures Claude ledger growth only from a known, nondecreasing baseline", () => { + const ledger = (output: number) => + claudeModelLedger({ "claude-sonnet-4-6": { inputTokens: 100, outputTokens: output } }); + expect(claudeLedgerDelta(ledger(10), ledger(25))).toEqual({ inputTokens: 0, outputTokens: 15 }); + expect(claudeLedgerDelta(ledger(25), ledger(10))).toBeUndefined(); + expect(claudeLedgerDelta(undefined, ledger(10))).toBeUndefined(); + expect(claudeModelLedger({})).toBeUndefined(); + }); + it("adds Claude steering segments to the final measured result once", () => { expect( normalizeClaudeTurnTokenUsage( diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index a1570986dc13a..58cf280e3c43c 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -4,7 +4,9 @@ import * as NodeOS from "node:os"; import * as NodeURL from "node:url"; import * as NodeFS from "node:fs"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; @@ -85,7 +87,7 @@ describe("AcpSessionRuntime", () => { additionalDirectories, }); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["session/new", "session/resume"] as const)( @@ -146,7 +148,7 @@ describe("AcpSessionRuntime", () => { expect( (yield* runtime.getConfigOptions).find((option) => option.category === "model"), ).toMatchObject({ currentValue: "gpt-5.4" }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("publishes model changes returned by a config request and live notifications", () => @@ -160,7 +162,7 @@ describe("AcpSessionRuntime", () => { expect((yield* updates)[0]?.configOptions).toEqual(selected.configOptions); yield* runtime.request("_test/startup-metadata", {}); expect((yield* updates)[0]?.configOptions).toEqual(yield* runtime.getConfigOptions); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("waits for native cancellation and drains final updates before another prompt", () => @@ -245,7 +247,7 @@ describe("AcpSessionRuntime", () => { } expect(yield* Fiber.join(replacement)).toMatchObject({ stopReason: "end_turn" }); expect(promptRequests).toBe(2); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reports an idle child exit and rejects later prompts", () => @@ -303,7 +305,7 @@ describe("AcpSessionRuntime", () => { ), ).toBe(event.error); expect(yield* runtime.notify("_test/exit", {}).pipe(Effect.flip)).toBe(event.error); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("retires a native runtime when its prompt caller is interrupted", () => @@ -343,7 +345,7 @@ describe("AcpSessionRuntime", () => { }) .pipe(Effect.flip), ).toBe(events[0]?.error); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("fails a pending request when the stderr handler rejects the runtime", () => @@ -365,7 +367,7 @@ describe("AcpSessionRuntime", () => { ); expect(events[0]?.error).toBe(failure); expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(failure); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -413,7 +415,7 @@ describe("AcpSessionRuntime", () => { }, }); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("drains large stderr output and keeps auth-sized logging chunks", () => @@ -439,7 +441,7 @@ describe("AcpSessionRuntime", () => { expect(lengths.length).toBeGreaterThan(0); expect(Math.max(...lengths)).toBeGreaterThanOrEqual(16_384); expect(Math.max(...lengths)).toBeLessThanOrEqual(32_768); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("releases a queued event drain when its runtime scope closes", () => @@ -469,7 +471,7 @@ describe("AcpSessionRuntime", () => { _tag: "AcpTransportError", detail: "The ACP session runtime is closed.", }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("does not restore ambient variables to a sanitized child environment", () => @@ -500,7 +502,7 @@ describe("AcpSessionRuntime", () => { inherited: false, explicit: true, }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it("selects explicit or agent-managed authentication without choosing terminal auth", () => { @@ -546,7 +548,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -598,7 +600,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -642,7 +644,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -684,7 +686,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -741,7 +743,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -779,7 +781,7 @@ describe("AcpSessionRuntime", () => { const afterRestart = yield* collectFirstAssistantItemId; expect(afterRestart).not.toBe(beforeRestart); - }).pipe(Effect.provide(NodeServices.layer)); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); }); it.effect("drops session updates emitted for a child ACP session", () => @@ -822,7 +824,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -883,7 +885,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -929,7 +931,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -976,7 +978,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1021,6 +1023,16 @@ describe("AcpSessionRuntime", () => { "logout", ]), ); + // Omitted roots clear an agent's stored ones, so every activation resends them. + for (const method of ["session/new", "session/fork", "session/resume"]) { + expect( + requestEvents.find((event) => event.method === method && event.status === "started") + ?.payload, + ).toMatchObject({ + additionalDirectories: ["/tmp/t3-attachments"], + _meta: { negotiated: "0.0.0" }, + }); + } }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -1032,6 +1044,10 @@ describe("AcpSessionRuntime", () => { T3_ACP_SESSION_LIFECYCLE: "1", }, }, + additionalDirectories: ["/tmp/t3-attachments"], + sessionMeta: (initializeResult) => ({ + negotiated: initializeResult.agentInfo?.version ?? "none", + }), cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, requestLogger: (event) => @@ -1041,7 +1057,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -1090,7 +1106,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1118,7 +1134,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1149,7 +1165,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1186,7 +1202,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1235,7 +1251,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), ); }); @@ -1272,7 +1288,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), ); }); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 8e0eed1371c5b..dc44035aad113 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -5,6 +5,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ChatAttachment, } from "@t3tools/contracts"; +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"; @@ -15,7 +16,9 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { applyAntigravityAcpModelSelection, + antigravitySessionMeta, buildAntigravityPrompt, + isQualifiedAntigravityRelease, } from "./AntigravityAcpSupport.ts"; const modelConfig = { @@ -38,22 +41,58 @@ function makeModelRuntime( if (failure) return yield* failure; selections.push(model); }); - const setConfigOption = Effect.fn("AntigravityAcpSupportTest.setConfigOption")(function* ( - configId: string, - value: string | boolean, - options?: { readonly allowUnlistedValue?: boolean }, - ) { - selections.push( - `${configId}=${String(value)}${options?.allowUnlistedValue === true ? " (unlisted)" : ""}`, - ); - return { configOptions }; - }); + const setConfigOption = Effect.fn("AntigravityAcpSupportTest.setConfigOption")( + ( + configId: string, + value: string | boolean, + options?: { readonly allowUnlistedValue?: boolean }, + ) => + Effect.sync(() => { + selections.push( + `${configId}=${String(value)}${options?.allowUnlistedValue === true ? " (unlisted)" : ""}`, + ); + return { configOptions }; + }), + ); return { runtime: { getConfigOptions: Effect.succeed(configOptions), setModel, setConfigOption }, selections, }; } +describe("Antigravity release gates", () => { + it.each([ + { version: "1.4.1", filter: false, native: false }, + { version: "1.4.2", filter: true, native: false }, + { version: "1.4.4", filter: true, native: false }, + { version: "1.4.5", filter: true, native: true }, + { version: "1.4.6", filter: false, native: false }, + { version: "1.3.0", filter: false, native: false }, + { version: undefined, filter: false, native: false }, + { version: "unknown", filter: false, native: false }, + ])( + "$version: workflow filter $filter, native reads and system prompt $native", + ({ version, filter, native }) => { + const initialize = { + protocolVersion: 1, + ...(version === undefined ? {} : { agentInfo: { name: "antigravity-acp", version } }), + }; + // Helpers send no append, so only the workflow filter can apply. + expect(antigravitySessionMeta(initialize)).toEqual( + filter ? { antigravity: { disabledTools: ["run_workflow"] } } : undefined, + ); + const antigravity = { + ...(filter ? { disabledTools: ["run_workflow"] } : {}), + ...(native ? { systemPrompt: { append: "" } } : {}), + }; + expect(antigravitySessionMeta(initialize, "")).toEqual( + filter || native ? { antigravity } : undefined, + ); + expect(isQualifiedAntigravityRelease(version)).toBe(native); + }, + ); +}); + describe("applyAntigravityAcpModelSelection", () => { it.effect("restores the saved model instead of the cold-resume default", () => Effect.gen(function* () { @@ -214,7 +253,9 @@ const makeAttachmentFixture = Effect.fn("AntigravityAcpSupportTest.makeAttachmen else yield* fs.writeFile(filePath, content); return { filePath, uri: (yield* path.toFileUrl(filePath)).href }; }); - return { fs, attachmentsDir, write }; + const resolve = (attachment: ChatAttachment) => + resolveAttachmentPath({ attachmentsDir, attachment }); + return { fs, attachmentsDir, resolve, write }; }, ); @@ -230,7 +271,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input: " Explain this image. ", attachments: [imageAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); @@ -251,7 +292,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input: undefined, attachments: [textAttachment], - attachmentsDir: selectedEnvironment.attachmentsDir, + resolveAttachmentPath: selectedEnvironment.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); @@ -282,7 +323,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input: "Inspect the pasted text only as needed.", attachments: [pastedText], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); @@ -309,7 +350,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input, attachments: [attachment, pdfAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -321,11 +362,100 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { ); expect(prompt).toEqual([ { type: "text", text: input }, - { type: "resource_link", uri: pdf.uri, name: "report.pdf", mimeType: "application/pdf" }, + { + type: "resource_link", + uri: pdf.uri, + name: "report.pdf", + mimeType: "application/pdf", + size: 50 * 1024 * 1024, + }, ]); }), ); + it.effect.each([ + { + case: "a Gemini model id", + model: { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + linked: true, + }, + { + case: "an opaque id named Gemini", + model: { id: "agy-model", name: "Gemini 4 Carbon" }, + linked: true, + }, + { case: "an unknown model", model: { id: "agy-custom", name: "Custom" }, linked: false }, + { case: "an unlisted opaque id", model: { id: "agy-custom", name: undefined }, linked: false }, + { case: "an unresolved model", model: undefined, linked: false }, + { + case: "a Claude model", + model: { id: "claude-opus-5-5", name: "Claude Opus 5.5" }, + linked: false, + }, + { + case: "an unqualified runtime", + agentVersion: "1.4.1", + model: { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + linked: false, + }, + { + case: "a Windows host", + platform: "win32" as const, + model: { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + linked: false, + }, + { + case: "a clip over 20 MiB", + model: { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + linked: false, + sizeBytes: 20 * 1024 * 1024 + 1, + }, + ])( + "links an MP4 for $case only when supported", + ({ agentVersion = "1.4.5", platform = "linux", model, linked, ...size }) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const clip = { + ...textAttachment, + id: "clip", + name: "clip.mp4", + mimeType: "video/mp4", + sizeBytes: 6 * 1024 * 1024, + ...size, + }; + const upload = yield* fixture.write(clip, ""); + yield* fixture.fs.truncate(upload.filePath, clip.sizeBytes); + const prompt = yield* buildAntigravityPrompt({ + input: "What colors appear?", + attachments: [clip], + resolveAttachmentPath: fixture.resolve, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, + agentVersion, + currentModel: model, + }).pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => Effect.die("Linked video is read by the agent").pipe(Stream.fromEffect), + }), + ); + expect(prompt).toEqual([ + { type: "text", text: "What colors appear?" }, + ...(linked + ? [ + { + type: "resource_link", + uri: upload.uri, + name: "clip.mp4", + mimeType: "video/mp4", + size: clip.sizeBytes, + }, + ] + : []), + ]); + }), + ); + it.effect.each([undefined, {}, { image: false, audio: false, embeddedContext: false }])( "keeps attachments path-only when native prompt capabilities are absent: %s", (promptCapabilities) => @@ -352,7 +482,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input, attachments: [imageAttachment, gifAttachment, pdfAttachment, textAttachment, audio], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities, }).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -366,7 +496,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { yield* buildAntigravityPrompt({ input: "Continue", attachments: [], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities, }), ).toEqual([{ type: "text", text: "Continue" }]); @@ -387,7 +517,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input: "Transcribe this.", attachments: [audioAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); @@ -409,7 +539,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input, attachments: [attachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities, }).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -430,7 +560,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: "Read this attachment.", attachments: [imageAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ @@ -456,7 +586,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const input = { input: `Read ${first.filePath} and ${second.filePath}`, attachments: [pdfAttachment, secondAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }; const prompt = yield* buildAntigravityPrompt(input); @@ -467,12 +597,14 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { uri: first.uri, name: "report.pdf", mimeType: "application/pdf", + size: (50 * 1024 * 1024) / 2, }, { type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf", + size: (50 * 1024 * 1024) / 2, }, ]); @@ -484,6 +616,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { uri: first.uri, name: "report.pdf", mimeType: "application/pdf", + size: (50 * 1024 * 1024) / 2, }, ]); }), @@ -498,7 +631,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: "Inspect both attachments.", attachments: [pdfAttachment, imageAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ @@ -517,7 +650,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: undefined, attachments: [pdfAttachment, textAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -551,7 +684,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: undefined, attachments: [textAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); @@ -569,7 +702,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: "Read this image.", attachments: [imageAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index ed8fff0379556..52fbd6c6070fb 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -6,7 +6,8 @@ import { type ProviderSendTurnInput, type RuntimeMode, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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"; @@ -17,7 +18,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { makeAntigravityStderrHandler, makeAntigravityStdoutTransform, @@ -34,21 +34,84 @@ export interface AntigravityAcpRuntimeInput extends Omit< | "clientInfo" | "onStderr" | "resumeMethod" + | "sessionMeta" | "transformSessionUpdate" | "transformStdout" > { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly onAuthorizationUrl?: (url: string) => Effect.Effect; + /** Serve `fs/read_text_file` for runtimes whose native reader is not qualified. */ readonly clientFileReads?: boolean; + /** The installed version, known before `initialize`; it picks the file reader. */ + readonly runtimeVersion?: string | null; + /** Conversational sessions only; helpers and probes leave the system prompt untouched. */ + readonly sessionSystemPromptAppend?: string; readonly authMethod?: AntigravityAuthMethod; } +function parseAntigravityVersion(version: string | null | undefined) { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version?.trim() ?? ""); + return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareVersions(left: ReadonlyArray, right: ReadonlyArray) { + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +/** + * 1.4.2 through 1.4.5 create or overwrite skills/system/run-workflow/SKILL.md in the workspace + * unless `run_workflow` is disabled. Older builds offer the tool and leave the file alone. + */ +function antigravityWritesRunWorkflowSkill(version: string | null | undefined): boolean { + const parsed = parseAntigravityVersion(version); + return ( + parsed !== undefined && + compareVersions(parsed, [1, 4, 2]) >= 0 && + compareVersions(parsed, [1, 4, 5]) <= 0 + ); +} + +/** + * Releases T3 qualified for native range reads and linked video. On 1.4.5 the client-read path + * sends a whole file to serve one line, and the native reader keeps diff edits. + */ +export function isQualifiedAntigravityRelease(version: string | null | undefined): boolean { + return version?.trim() === "1.4.5"; +} + +/** + * 1.4.5 appends `systemPrompt.append` to its system instructions on new, load and resume, and + * keeps none of it between processes, so every activation sends it again. + */ +export const antigravityAppendsSystemPrompt = ( + initializeResult: EffectAcpSchema.InitializeResponse, +) => isQualifiedAntigravityRelease(initializeResult.agentInfo?.version); + +export function antigravitySessionMeta( + initializeResult: EffectAcpSchema.InitializeResponse, + systemPromptAppend?: string, +): EffectAcpSchema.NewSessionRequest["_meta"] | undefined { + const antigravity = { + ...(antigravityWritesRunWorkflowSkill(initializeResult.agentInfo?.version) + ? { disabledTools: ["run_workflow"] } + : {}), + ...(systemPromptAppend !== undefined && antigravityAppendsSystemPrompt(initializeResult) + ? { systemPrompt: { append: systemPromptAppend } } + : {}), + }; + return Object.keys(antigravity).length === 0 ? undefined : { antigravity }; +} + export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(function* ( input: AntigravityAcpRuntimeInput, ): Effect.fn.Return< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Crypto.Crypto | Scope.Scope + SecureRandom.SecureRandom | Scope.Scope > { const context = yield* Layer.build( AcpSessionRuntime.layer({ @@ -59,9 +122,15 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( cancelBehavior: "wait-for-prompt", // The agent offers non-Gemini models only to allowlisted clients such as Zed. clientInfo: { name: "zed", version: "0.0.0" }, + sessionMeta: (initializeResult) => + antigravitySessionMeta(initializeResult, input.sessionSystemPromptAppend), clientCapabilities: { // Advertising writes swaps the agent's diff edits for whole-file client_edit_file. - fs: { readTextFile: input.clientFileReads === true, writeTextFile: false }, + fs: { + readTextFile: + input.clientFileReads === true && !isQualifiedAntigravityRelease(input.runtimeVersion), + writeTextFile: false, + }, terminal: false, }, transformStdout: makeAntigravityStdoutTransform( @@ -212,6 +281,18 @@ const AUDIO_MIME_TYPES = new Set([ "audio/webm", ]); const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; +// The agent inlines linked video bytes into one model request, so keep it under the inline limit. +const ANTIGRAVITY_MAX_VIDEO_ATTACHMENT_BYTES = 20 * 1024 * 1024; + +/** + * Only Gemini takes video. The evidence is the session's resolved model, so an account default + * counts once the agent reports it; opaque ids such as `agy-model` need a Gemini display name. + */ +function antigravityModelAcceptsVideo( + model: { readonly id: string; readonly name: string | undefined } | undefined, +): boolean { + return model !== undefined && (/^gemini-/i.test(model.id) || /^gemini\b/i.test(model.name ?? "")); +} const TEXT_MIME_TYPES = new Set([ "application/json", "application/ld+json", @@ -277,8 +358,13 @@ const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { readonly input: ProviderSendTurnInput["input"]; readonly attachments: ProviderSendTurnInput["attachments"]; - readonly attachmentsDir: string; + readonly resolveAttachmentPath: ( + attachment: NonNullable[number], + ) => string | null; readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"]; + /** Linked video needs a runtime whose `resource_link` handling is qualified and a Gemini model. */ + readonly agentVersion?: string | undefined; + readonly currentModel?: { readonly id: string; readonly name: string | undefined } | undefined; }): Effect.fn.Return< ReadonlyArray, EffectAcpErrors.AcpError, @@ -286,6 +372,8 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + // The agent runs on this host. + const platform = yield* HostProcess.Platform; const blocks: Array = []; const text = input.input?.trim(); if (text) blocks.push({ type: "text", text }); @@ -301,6 +389,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); const pdf = attachment.type === "file" && mimeType === "application/pdf"; + const video = + attachment.type === "file" && + mimeType === "video/mp4" && + isQualifiedAntigravityRelease(input.agentVersion) && + // The agent turns a file URI into a path with unquote(urlparse(uri).path), which drops + // Windows drives and UNC hosts. Keep the saved path there until Google fixes it. + platform !== "win32" && + antigravityModelAcceptsVideo(input.currentModel); const textFile = attachment.type === "file" && (mimeType.startsWith("text/") || @@ -314,11 +410,8 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ? input.promptCapabilities?.audio !== true : pdf || textFile ? input.promptCapabilities?.embeddedContext !== true - : true))); - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: input.attachmentsDir, - attachment, - }); + : !video))); + const attachmentPath = input.resolveAttachmentPath(attachment); if (!attachmentPath) { return yield* EffectAcpErrors.AcpRequestError.invalidParams( `Invalid attachment '${attachment.name}'.`, @@ -346,7 +439,9 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES : pdf ? MAX_TOTAL_ATTACHMENT_BYTES - : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; + : video + ? ANTIGRAVITY_MAX_VIDEO_ATTACHMENT_BYTES + : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; if ( attachment.type === "file" && (size > limit || totalBytes + size > MAX_TOTAL_ATTACHMENT_BYTES) @@ -365,8 +460,8 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi EffectAcpErrors.AcpRequestError.invalidParams(`Invalid attachment '${attachment.name}'.`), ), ); - if (pdf) { - blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType }); + if (pdf || video) { + blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType, size }); continue; } const bytes = yield* fileSystem.stream(attachmentPath, { bytesToRead: limit + 1 }).pipe( diff --git a/apps/server/src/provider/acp/AntigravityChildWork.test.ts b/apps/server/src/provider/acp/AntigravityChildWork.test.ts new file mode 100644 index 0000000000000..78927b09d74e3 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityChildWork.test.ts @@ -0,0 +1,344 @@ +// @effect-diagnostics nodeBuiltinImport:off -- fixtures name transcript files by URL. +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + ANTIGRAVITY_SUBAGENT_BATCH_TITLE, + antigravitySubagentLaunches, + describeAntigravitySubagentLaunches, + readAntigravityChildWork, +} from "./AntigravityChildWork.ts"; +import { sanitizeAntigravityToolPayload } from "./AntigravityProtocol.ts"; +import { + autoApproved, + CHILD, + invokeStep, + LAUNCH, + makeProfile, + plannerStep, + ROOT, + transcriptLines, +} from "./AntigravityChildWork.testkit.ts"; + +const read = ( + profileDirectory: string, + nativeTaskId: string, + launch: { readonly title: string | null; readonly prompt: string } = { + title: null, + prompt: "Readme check", + }, + siblings: ReadonlyArray<{ + readonly nativeTaskId: string; + readonly title: string | null; + readonly prompt: string; + }> = [], +) => + readAntigravityChildWork({ + profileDirectory, + rootSessionId: ROOT, + nativeTaskId, + launch, + siblings, + }); + +it.layer(NodeServices.layer)("readAntigravityChildWork", (it) => { + it.effect("follows an auto-approved call id through its planner step to the record", () => + Effect.gen(function* () { + const { profile } = yield* makeProfile({ steps: autoApproved }); + const [child, ...rest] = yield* read(profile, `${ROOT}:1`); + expect(rest).toEqual([]); + expect(child).toMatchObject({ + conversationId: CHILD, + role: LAUNCH.role, + prompt: LAUNCH.prompt, + truncated: false, + }); + // The launch envelope repeats the prompt shown above, so the steps start with the child's work. + expect(child?.steps?.map((step) => step.text ?? step.toolCalls[0]?.name)).toEqual([ + "view_file", + "2: child read marker 4242", + "marker 4242 found", + ]); + expect(child?.steps?.[0]?.toolCalls[0]?.input).toBe('{"AbsolutePath":"/project/README.md"}'); + }), + ); + + it.effect("needs proof that the call id belongs to the parent's record", () => + Effect.gen(function* () { + const { profile } = yield* makeProfile({ + steps: (logUri) => [ + ...autoApproved(logUri), + [5, 15, plannerStep(["call-b", "call-c"])], + [ + 6, + 127, + invokeStep({ callId: "call-b", launches: [LAUNCH], children: [{ id: CHILD, logUri }] }), + ], + [ + 7, + 127, + invokeStep({ callId: "call-c", launches: [LAUNCH], children: [{ id: CHILD, logUri }] }), + ], + ], + }); + // Another session's id, a step that is not the planner call, and parallel calls that share one id. + expect(yield* read(profile, `00000000-0000-4000-8000-000000000000:1`)).toEqual([]); + expect(yield* read(profile, `${ROOT}:2`)).toEqual([]); + expect(yield* read(profile, `${ROOT}:5`)).toEqual([]); + }), + ); + + it.effect("matches a permission-prompt call by its launch text, only when unique", () => + Effect.gen(function* () { + const other = { role: "Test Runner", prompt: "Run the tests" }; + const long = { role: "Long Reader", prompt: `Read this:\n${"x".repeat(9_000)}` }; + // A role's leading byte order mark is part of its text. + const marked = { role: "\uFEFFMarked", prompt: "Check the mark" }; + const { profile } = yield* makeProfile({ + steps: (logUri) => [ + [ + 2, + 127, + invokeStep({ callId: "x", launches: [LAUNCH], children: [{ id: CHILD, logUri }] }), + ], + [ + 8, + 127, + invokeStep({ callId: "w", launches: [long], children: [{ id: CHILD, logUri }] }), + ], + [ + 10, + 127, + invokeStep({ callId: "v", launches: [marked], children: [{ id: CHILD, logUri }] }), + ], + [ + 4, + 127, + invokeStep({ callId: "y", launches: [other], children: [{ id: CHILD, logUri }] }), + ], + [ + 6, + 127, + invokeStep({ callId: "z", launches: [other], children: [{ id: CHILD, logUri }] }), + ], + ], + }); + const opaque = "35bea692fa95447fbdae6333fec5515f"; + const matched = yield* read(profile, opaque, describeAntigravitySubagentLaunches([LAUNCH])); + expect(matched.map((child) => child.role)).toEqual([LAUNCH.role]); + // T3 knows a second batch with this text that the agent has not recorded yet. + const twin = { nativeTaskId: "f00d", ...describeAntigravitySubagentLaunches([LAUNCH]) }; + expect( + yield* read(profile, opaque, describeAntigravitySubagentLaunches([LAUNCH]), [twin]), + ).toEqual([]); + // T3 stores the launch from the bounded tool payload, which keeps only a long prompt's tail. + const stored = antigravitySubagentLaunches( + sanitizeAntigravityToolPayload({ Subagents: [{ Role: long.role, Prompt: long.prompt }] }), + ); + const longMatch = yield* read(profile, opaque, describeAntigravitySubagentLaunches(stored!)); + expect(longMatch.map((child) => child.prompt)).toEqual([long.prompt]); + expect( + yield* read(profile, opaque, describeAntigravitySubagentLaunches([marked])), + ).toHaveLength(1); + expect(yield* read(profile, opaque, { title: "Marked", prompt: marked.prompt })).toEqual([]); + expect(yield* read(profile, opaque, describeAntigravitySubagentLaunches([other]))).toEqual( + [], + ); + expect(yield* read(profile, opaque, { title: null, prompt: "Readme check" })).toEqual([]); + }), + ); + + it.effect("leaves an auto-approved batch's record out of a launch-text match", () => + Effect.gen(function* () { + const { profile } = yield* makeProfile({ steps: autoApproved }); + const launch = describeAntigravitySubagentLaunches([LAUNCH]); + const opaque = "35bea692fa95447fbdae6333fec5515f"; + expect(yield* read(profile, opaque, launch)).toHaveLength(1); + // An earlier auto-approved batch stored no launch text, but its planner step claims the record. + const auto = { + nativeTaskId: `${ROOT}:1`, + title: ANTIGRAVITY_SUBAGENT_BATCH_TITLE, + prompt: "", + }; + expect(yield* read(profile, opaque, launch, [auto])).toEqual([]); + // A sibling that cannot be ruled out refuses the match: its planner step is unreadable, + // or it is a permission batch whose launch text is missing or cut. + for (const sibling of [ + { ...auto, nativeTaskId: `${ROOT}:99` }, + { nativeTaskId: "0ld", title: ANTIGRAVITY_SUBAGENT_BATCH_TITLE, prompt: "Running" }, + // Its stored launch was cut, so it may be this same launch. + { nativeTaskId: "cut", title: LAUNCH.role, prompt: "[Earlier output truncated]\n\nmarker" }, + { nativeTaskId: "cut", title: "[Earlier output truncated]\n\nReader", prompt: "x" }, + { nativeTaskId: "cut", title: null, prompt: "[Earlier output truncated]\n\nx" }, + ]) { + expect(yield* read(profile, opaque, launch, [sibling])).toEqual([]); + } + // A cut prompt under different roles is another launch. + const other = { + nativeTaskId: "cut", + title: "Test Runner", + prompt: "[Earlier output truncated]\n\nx", + }; + expect(yield* read(profile, opaque, launch, [other])).toHaveLength(1); + }), + ); + + it.effect("reads no transcript outside the child's own profile path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-outside-" }); + const secret = path.join(outside, "transcript.jsonl"); + yield* fs.writeFileString(secret, JSON.stringify(transcriptLines[3]) + "\n"); + const named = yield* makeProfile({ + steps: () => autoApproved(NodeURL.pathToFileURL(secret).href), + }); + const [namedChild] = yield* read(named.profile, `${ROOT}:1`); + expect(namedChild?.role).toBe(LAUNCH.role); + expect(namedChild?.steps).toBeNull(); + + const linked = yield* makeProfile({ steps: autoApproved }); + yield* fs.remove(linked.transcriptPath); + yield* fs.symlink(secret, linked.transcriptPath); + const [linkedChild] = yield* read(linked.profile, `${ROOT}:1`); + expect(linkedChild?.steps).toBeNull(); + }), + ); + + it.effect("reads only the tail of a large transcript", () => + Effect.gen(function* () { + const filler = JSON.stringify({ + step_index: 0, + type: "VIEW_FILE", + status: "DONE", + content: "x".repeat(10_000), + }); + const { profile } = yield* makeProfile({ + steps: autoApproved, + transcript: `${Array.from({ length: 40 }, () => filler).join("\n")}\n${JSON.stringify(transcriptLines[3])}\n`, + }); + const [child] = yield* read(profile, `${ROOT}:1`); + expect(child?.truncated).toBe(true); + expect(child?.steps?.length).toBeLessThan(40); + expect(child?.steps?.at(-1)?.text).toBe("marker 4242 found"); + expect(child?.steps?.[0]?.text?.length).toBeLessThanOrEqual(4_001); + }), + ); + + it.effect("refuses links between children and sessions inside the same profile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sibling = "b468c0e1d2f34a5b6c7d8e9f0a1b2c3d"; + // The record names this child's literal path, but the child directory links to a sibling. + const linked = yield* makeProfile({ steps: autoApproved }); + const brain = path.join(linked.profile, "antigravity-acp", "brain"); + yield* fs.rename(path.join(brain, CHILD), path.join(brain, sibling)); + yield* fs.symlink(path.join(brain, sibling), path.join(brain, CHILD)); + const [child] = yield* read(linked.profile, `${ROOT}:1`); + expect(child?.role).toBe(LAUNCH.role); + expect(child?.steps).toBeNull(); + + // This session's database links to another session's records. + const other = yield* makeProfile({ steps: autoApproved }); + const conversations = path.join(other.profile, "antigravity-acp", "conversations"); + const peer = path.join(conversations, "00000000-0000-4000-8000-000000000000.db"); + yield* fs.rename(path.join(conversations, `${ROOT}.db`), peer); + yield* fs.symlink(peer, path.join(conversations, `${ROOT}.db`)); + expect(yield* read(other.profile, `${ROOT}:1`)).toEqual([]); + }), + ); + + it.effect("declines a launch-text match it cannot prove unique within its scan bounds", () => + Effect.gen(function* () { + const record = (callId: string, logUri: string) => + invokeStep({ callId, launches: [LAUNCH], children: [{ id: CHILD, logUri }] }); + const other = { role: "Test Runner", prompt: "Run the tests" }; + const filler = (idx: number, logUri: string) => + [ + idx, + 127, + invokeStep({ callId: `f${idx}`, launches: [other], children: [{ id: CHILD, logUri }] }), + ] as const; + const opaque = "35bea692fa95447fbdae6333fec5515f"; + const launch = describeAntigravitySubagentLaunches([LAUNCH]); + const cases = [ + // More records than one open may scan. + (logUri: string) => [ + [1, 127, record("x", logUri)] as const, + ...Array.from({ length: 200 }, (_, index) => filler(index + 2, logUri)), + ], + // An oversized record could hold an identical launch. + (logUri: string) => [ + [1, 127, record("x", logUri)] as const, + [2, 127, new Uint8Array(1024 * 1024 + 1)] as const, + ], + // So could one this reader cannot decode. + (logUri: string) => [ + [1, 127, record("x", logUri)] as const, + [2, 127, Uint8Array.from([0xff, 0xff])] as const, + ], + ]; + for (const steps of cases) { + const { profile } = yield* makeProfile({ steps }); + expect(yield* read(profile, opaque, launch)).toEqual([]); + } + }), + ); + + it.effect("keeps the batch view for a batch over the child limit", () => + Effect.gen(function* () { + const { profile } = yield* makeProfile({ + steps: (logUri) => [ + [1, 15, plannerStep(["call-a"])], + [ + 2, + 127, + invokeStep({ + callId: "call-a", + launches: Array.from({ length: 17 }, () => LAUNCH), + children: Array.from({ length: 17 }, () => ({ id: CHILD, logUri })), + }), + ], + ], + }); + expect(yield* read(profile, `${ROOT}:1`)).toEqual([]); + }), + ); + + it.effect("shares one transcript budget across the batch", () => + Effect.gen(function* () { + const filler = JSON.stringify({ + step_index: 0, + type: "VIEW_FILE", + status: "DONE", + content: "x".repeat(1_000), + }); + const transcript = `${Array.from({ length: 300 }, () => filler).join("\n")}\n`; + const { profile } = yield* makeProfile({ + transcript, + steps: (logUri) => [ + [1, 15, plannerStep(["call-a"])], + [ + 2, + 127, + invokeStep({ + callId: "call-a", + launches: Array.from({ length: 16 }, () => LAUNCH), + children: Array.from({ length: 16 }, () => ({ id: CHILD, logUri })), + }), + ], + ], + }); + const children = yield* read(profile, `${ROOT}:1`); + expect(children).toHaveLength(16); + expect(children.every((child) => child.truncated)).toBe(true); + expect(JSON.stringify(children).length).toBeLessThan(600 * 1024); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravityChildWork.testkit.ts b/apps/server/src/provider/acp/AntigravityChildWork.testkit.ts new file mode 100644 index 0000000000000..82a2befbf6698 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityChildWork.testkit.ts @@ -0,0 +1,111 @@ +// @effect-diagnostics nodeBuiltinImport:off -- fixtures write a native session database. +import * as NodeSqlite from "node:sqlite"; +import * as NodeURL from "node:url"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +export const ROOT = "7d40e54d-5977-4239-940b-246cdd81a12b"; +export const CHILD = "a357bf2720914589dd1ec49f7a816115"; +export const LAUNCH = { role: "Readme Reader", prompt: "Read README.md and report the marker" }; + +// Mirrors the 1.4.5 runtime: Step{5: metadata{4: tool call{1 id, 2 name}}, 20: planner{7: calls}, +// 143: invoke_subagent{9: specs{2 role, 3 prompt}, 10: results{1 conversation, 2 log uri}}}. +const varint = (value: number): number[] => { + const bytes: number[] = []; + while (value >= 0x80) { + bytes.push((value & 0x7f) | 0x80); + value = Math.floor(value / 0x80); + } + return [...bytes, value]; +}; +const field = (number: number, value: string | Uint8Array) => { + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; + return [...varint(number * 8 + 2), ...varint(bytes.length), ...bytes]; +}; +const proto = (...parts: number[][]) => Uint8Array.from(parts.flat()); +const toolCall = (id: string) => proto(field(1, id), field(2, "invoke_subagent")); + +export const invokeStep = (input: { + readonly callId: string; + readonly launches: ReadonlyArray; + readonly children: ReadonlyArray<{ readonly id: string; readonly logUri: string }>; +}) => + proto( + [...varint(1 * 8), ...varint(127)], + field(5, proto(field(4, toolCall(input.callId)))), + field( + 143, + proto( + ...input.launches.map((launch) => + field(9, proto(field(1, "research"), field(2, launch.role), field(3, launch.prompt))), + ), + ...input.children.map((child) => + field(10, proto(field(1, child.id), field(2, child.logUri))), + ), + ), + ), + ); +export const plannerStep = (callIds: ReadonlyArray) => + proto(field(20, proto(...callIds.map((id) => field(7, toolCall(id)))))); + +export const transcriptLines = [ + // Trimmed from 1.4.5: the launch envelope around the prompt the parent record already holds. + { + step_index: 0, + type: "SYSTEM_MESSAGE", + status: "DONE", + content: `\n${LAUNCH.prompt}\n`, + }, + { + step_index: 1, + type: "PLANNER_RESPONSE", + status: "DONE", + // The runtime stores each argument value as JSON text. + tool_calls: [{ name: "view_file", args: { AbsolutePath: '"/project/README.md"' } }], + }, + { step_index: 2, type: "VIEW_FILE", status: "DONE", content: "2: child read marker 4242" }, + { step_index: 3, type: "PLANNER_RESPONSE", status: "DONE", content: "marker 4242 found" }, +]; + +export const makeProfile = Effect.fn("makeProfile")(function* (input: { + /** Defaults to a scoped temp directory. */ + readonly profile?: string; + readonly steps: (logUri: string) => ReadonlyArray; + readonly transcript?: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profile = input.profile ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-child-" })); + const acp = path.join(profile, "antigravity-acp"); + const logs = path.join(acp, "brain", CHILD, ".system_generated", "logs"); + yield* fs.makeDirectory(path.join(acp, "conversations"), { recursive: true }); + yield* fs.makeDirectory(logs, { recursive: true }); + const transcriptPath = path.join(logs, "transcript.jsonl"); + yield* fs.writeFileString( + transcriptPath, + input.transcript ?? transcriptLines.map((line) => JSON.stringify(line)).join("\n") + "\n", + ); + const logUri = NodeURL.pathToFileURL(transcriptPath).href; + const database = new NodeSqlite.DatabaseSync(path.join(acp, "conversations", `${ROOT}.db`)); + database.exec( + "CREATE TABLE steps (idx integer PRIMARY KEY, step_type integer NOT NULL DEFAULT 0, step_format integer NOT NULL DEFAULT 0, step_payload blob)", + ); + const insert = database.prepare( + "INSERT INTO steps (idx, step_type, step_payload) VALUES (?, ?, ?)", + ); + for (const [idx, type, payload] of input.steps(logUri)) insert.run(idx, type, payload); + database.close(); + return { profile, transcriptPath, logUri }; +}); + +export const autoApproved = (logUri: string) => + [ + [1, 15, plannerStep(["call-a"])], + [ + 2, + 127, + invokeStep({ callId: "call-a", launches: [LAUNCH], children: [{ id: CHILD, logUri }] }), + ], + ] as const; diff --git a/apps/server/src/provider/acp/AntigravityChildWork.ts b/apps/server/src/provider/acp/AntigravityChildWork.ts new file mode 100644 index 0000000000000..ea12e0e2b20de --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityChildWork.ts @@ -0,0 +1,478 @@ +// @effect-diagnostics nodeBuiltinImport:off -- node:sqlite reads the native session database read-only. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeURL from "node:url"; + +import type { + OrchestrationV2NativeChildStep, + OrchestrationV2NativeChildWork, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import { antigravityManagedPath, antigravityProfileRoot } from "./AntigravityProfilePaths.ts"; +import { boundAntigravityToolText, isCutAntigravityToolText } from "./AntigravityProtocol.ts"; + +// Field numbers from the runtime's trajectory protos (exa.cortex_pb). +const STEP_METADATA = 5; +const METADATA_TOOL_CALL = 4; +const STEP_PLANNER_RESPONSE = 20; +const PLANNER_TOOL_CALLS = 7; +const STEP_INVOKE_SUBAGENT = 143; +const INVOKE_SPECS = 9; +const INVOKE_RESULTS = 10; +const INVOKE_SUBAGENT_STEP_TYPE = 127; + +// One open reads at most this much; past any bound the batch view stays as it was. +const MAX_STEP_BYTES = 1024 * 1024; +const MAX_INVOKE_ROWS = 200; +const MAX_SCAN_BYTES = 8 * 1024 * 1024; +const MAX_CHILDREN = 16; +const TRANSCRIPT_BYTE_CAP = 256 * 1024; +const BATCH_TRANSCRIPT_BYTES = 512 * 1024; +const STEP_TEXT_CAP = 4_000; +const TOOL_INPUT_CAP = 1_000; + +/** T3's title for a batch whose launch text it never saw. */ +export const ANTIGRAVITY_SUBAGENT_BATCH_TITLE = "Antigravity subagent batch"; + +interface AntigravityChildLaunch { + readonly role: string; + readonly prompt: string; +} + +const decodeLaunchInput = Schema.decodeUnknownOption( + Schema.Struct({ + Subagents: Schema.NonEmptyArray(Schema.Struct({ Role: Schema.String, Prompt: Schema.String })), + }), +); + +/** `invoke_subagent` arguments, from the permission prompt or the persisted record. */ +export function antigravitySubagentLaunches( + input: unknown, +): ReadonlyArray | undefined { + return Option.getOrUndefined( + Option.map(decodeLaunchInput(input), (decoded) => + decoded.Subagents.map((entry) => ({ role: entry.Role, prompt: entry.Prompt })), + ), + ); +} + +/** Batch title and prompt; reads match permission-prompt tool call ids against this exact text. */ +export function describeAntigravitySubagentLaunches( + launches: ReadonlyArray, +) { + const [only] = launches; + return launches.length === 1 && only !== undefined + ? { title: only.role, prompt: only.prompt } + : { + title: launches.map((launch) => launch.role).join(", "), + prompt: launches.map((launch) => `${launch.role}\n${launch.prompt}`).join("\n\n"), + }; +} + +/** Length-delimited fields only; varints and fixed values are skipped. */ +function protoFields(bytes: Uint8Array): ReadonlyArray { + const fields: Array = []; + let offset = 0; + const varint = () => { + let value = 0; + for (let shift = 0; shift < 64; shift += 7) { + const byte = bytes[offset++]; + if (byte === undefined) throw new Error("Truncated varint"); + value += (byte & 0x7f) * 2 ** shift; + if (byte < 0x80) return value; + } + throw new Error("Overlong varint"); + }; + while (offset < bytes.length) { + const key = varint(); + const wire = key % 8; + const field = Math.floor(key / 8); + if (wire === 0) varint(); + else if (wire === 1) offset += 8; + else if (wire === 5) offset += 4; + else if (wire === 2) { + const length = varint(); + if (offset + length > bytes.length) throw new Error("Truncated field"); + fields.push([field, bytes.subarray(offset, offset + length)]); + offset += length; + } else throw new Error("Unsupported wire type"); + } + if (offset > bytes.length) throw new Error("Truncated field"); + return fields; +} + +// Keep a leading byte order mark: T3 stores launch text with it. +const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const messages = (bytes: Uint8Array, field: number) => + protoFields(bytes).flatMap(([number, value]) => (number === field ? [value] : [])); +const message = (bytes: Uint8Array | undefined, field: number) => + bytes === undefined ? undefined : messages(bytes, field)[0]; +const text = (bytes: Uint8Array | undefined, field: number) => { + const value = message(bytes, field); + return value === undefined ? undefined : textDecoder.decode(value); +}; + +interface InvokeRecord { + readonly toolCallId: string | undefined; + readonly launches: ReadonlyArray; + readonly children: ReadonlyArray<{ readonly conversationId: string; readonly logUri: string }>; +} + +const NATIVE_ID = /^[A-Za-z0-9-]{1,64}$/; + +/** Undefined for steps this reader cannot decode, so they never count as proof. */ +const safely = + (decode: (payload: Uint8Array) => A) => + (payload: Uint8Array): A | undefined => { + try { + return decode(payload); + } catch { + return undefined; + } + }; + +const decodeInvokeRecord = safely((payload): InvokeRecord | undefined => { + const invoke = message(payload, STEP_INVOKE_SUBAGENT); + if (invoke === undefined) return undefined; + const children = messages(invoke, INVOKE_RESULTS).map((result) => ({ + conversationId: text(result, 1) ?? "", + logUri: text(result, 2) ?? "", + })); + if (!children.every((child) => NATIVE_ID.test(child.conversationId))) return undefined; + return { + toolCallId: text(message(message(payload, STEP_METADATA), METADATA_TOOL_CALL), 1), + launches: messages(invoke, INVOKE_SPECS).map((spec) => ({ + role: text(spec, 2) ?? "", + prompt: text(spec, 3) ?? "", + })), + children, + }; +}); + +const plannerInvokeCallIds = safely((payload): ReadonlyArray => { + const planner = message(payload, STEP_PLANNER_RESPONSE); + if (planner === undefined) return []; + return messages(planner, PLANNER_TOOL_CALLS).flatMap((call) => { + const id = text(call, 1); + return text(call, 2) === "invoke_subagent" && id !== undefined ? [id] : []; + }); +}); + +const payloadOf = (row: unknown) => + row !== null && + typeof row === "object" && + "step_payload" in row && + row.step_payload instanceof Uint8Array + ? row.step_payload + : undefined; + +interface BatchLaunch { + readonly title: string | null; + readonly prompt: string; +} + +const plannerCallIdsAt = (database: NodeSqlite.DatabaseSync, plannerIdx: number) => { + const planner = payloadOf( + database + .prepare( + "SELECT step_payload FROM steps WHERE idx = ? AND step_format = 0 AND length(step_payload) <= ?", + ) + .get(plannerIdx, MAX_STEP_BYTES), + ); + return planner === undefined ? undefined : plannerInvokeCallIds(planner); +}; + +/** + * Auto-approved calls carry `:`; the record with that planner call's id + * is the proof. Permission prompts carry a random id, so the launch text must match one record + * no auto-approved sibling claims, and no sibling may share the text. + */ +function findInvokeRecord( + database: NodeSqlite.DatabaseSync, + input: { + readonly rootSessionId: string; + readonly nativeTaskId: string; + readonly launch: BatchLaunch; + readonly siblings: ReadonlyArray; + }, +): InvokeRecord | undefined { + const plannerIdxOf = (nativeTaskId: string) => { + const stepId = /^(.+):(\d+)$/.exec(nativeTaskId); + return stepId === null ? undefined : stepId[1] === input.rootSessionId ? Number(stepId[2]) : -1; + }; + const plannerIdx = plannerIdxOf(input.nativeTaskId); + if (plannerIdx !== undefined) { + if (plannerIdx < 0) return undefined; + const callIds = plannerCallIdsAt(database, plannerIdx) ?? []; + const [callId] = callIds; + if (callIds.length !== 1) return undefined; + // The record follows the planner step that issued the call. + let rows = 0; + let bytes = 0; + for (const row of database + .prepare( + // SQLite returns only in-bound payloads, so an oversized row never reaches this process. + "SELECT length(step_payload) AS size, CASE WHEN length(step_payload) <= ? THEN step_payload END AS step_payload FROM steps WHERE step_type = ? AND step_format = 0 AND idx > ? ORDER BY idx", + ) + .iterate(MAX_STEP_BYTES, INVOKE_SUBAGENT_STEP_TYPE, plannerIdx)) { + const size = row !== null && typeof row === "object" && "size" in row ? Number(row.size) : 0; + rows += 1; + bytes += size; + if (rows > MAX_INVOKE_ROWS || size > MAX_STEP_BYTES || bytes > MAX_SCAN_BYTES) + return undefined; + const payload = payloadOf(row); + const record = payload === undefined ? undefined : decodeInvokeRecord(payload); + if (record !== undefined && record.toolCallId === callId) return complete(record); + } + return undefined; + } + const totals = database + .prepare( + "SELECT count(*) AS rows, coalesce(sum(length(step_payload)), 0) AS bytes, coalesce(max(length(step_payload)), 0) AS largest, sum(step_format != 0) AS other FROM steps WHERE step_type = ?", + ) + .get(INVOKE_SUBAGENT_STEP_TYPE); + const total = (key: string) => + totals !== undefined && key in totals ? Number(totals[key as keyof typeof totals] ?? 0) : 0; + const sameText = (launch: BatchLaunch) => + launch.title === input.launch.title && launch.prompt === input.launch.prompt; + if ( + input.siblings.some(sameText) || + total("rows") > MAX_INVOKE_ROWS || + total("bytes") > MAX_SCAN_BYTES || + total("largest") > MAX_STEP_BYTES || + total("other") > 0 + ) { + return undefined; + } + const claimed = new Set(); + for (const sibling of input.siblings) { + const idx = plannerIdxOf(sibling.nativeTaskId); + // A sibling whose launch text is missing, or cut under the same roles, or whose planner step + // is unreadable, cannot be ruled out. + if (idx === undefined) { + if ( + sibling.title === ANTIGRAVITY_SUBAGENT_BATCH_TITLE || + (sibling.title !== null && isCutAntigravityToolText(sibling.title)) || + (isCutAntigravityToolText(sibling.prompt) && + (sibling.title === null || sibling.title === input.launch.title)) + ) + return undefined; + continue; + } + if (idx < 0) continue; + const callIds = plannerCallIdsAt(database, idx); + if (callIds === undefined || callIds.length === 0) return undefined; + for (const callId of callIds) claimed.add(callId); + } + const matches: Array = []; + for (const row of database + .prepare("SELECT step_payload FROM steps WHERE step_type = ? ORDER BY idx") + .iterate(INVOKE_SUBAGENT_STEP_TYPE)) { + const payload = payloadOf(row); + const record = payload === undefined ? undefined : decodeInvokeRecord(payload); + // An unreadable record could be an identical launch, so uniqueness is unproven. + if (record === undefined) return undefined; + if (record.toolCallId !== undefined && claimed.has(record.toolCallId)) continue; + // T3 stored the launch from the bounded tool payload, so bound the record the same way. + const described = describeAntigravitySubagentLaunches( + record.launches.map((launch) => ({ + role: boundAntigravityToolText(launch.role), + prompt: boundAntigravityToolText(launch.prompt), + })), + ); + if (sameText(described)) { + matches.push(record); + } + } + const [match] = matches; + return matches.length === 1 && match !== undefined ? complete(match) : undefined; +} + +const complete = (record: InvokeRecord) => + record.launches.length === record.children.length && record.children.length <= MAX_CHILDREN + ? record + : undefined; + +const TranscriptLine = Schema.Struct({ + step_index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + type: Schema.String, + status: Schema.optional(Schema.String), + content: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + tool_calls: Schema.optional( + Schema.Array(Schema.Struct({ name: Schema.String, args: Schema.optional(Schema.Unknown) })), + ), +}); +const decodeTranscriptLine = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptLine)); + +// The runtime records each tool argument value as JSON text. +const decodeArgument = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +const toolArguments = (args: unknown) => + Predicate.isObject(args) + ? Object.fromEntries( + Object.entries(args).map(([key, value]) => [ + key, + typeof value === "string" ? Option.getOrElse(decodeArgument(value), () => value) : value, + ]), + ) + : (args ?? {}); + +const capText = (value: string, limit: number) => + value.length <= limit ? value : `${value.slice(0, limit)}…`; + +/** Skips the runtime's opening envelope around the launch prompt the view already shows. */ +function transcriptSteps( + contents: string, + prompt: string, +): ReadonlyArray { + const launchPrompt = prompt.trim(); + return contents.split("\n").flatMap((line) => + Option.match(decodeTranscriptLine(line), { + onNone: () => [], + onSome: (step) => + step.step_index === 0 && + step.type === "SYSTEM_MESSAGE" && + launchPrompt !== "" && + step.content?.includes(launchPrompt) === true + ? [] + : [ + { + index: step.step_index, + type: step.type, + status: step.status ?? null, + text: + step.content !== undefined || step.error !== undefined + ? capText(step.content ?? step.error ?? "", STEP_TEXT_CAP) + : null, + toolCalls: (step.tool_calls ?? []).map((call) => ({ + name: call.name, + input: capText(JSON.stringify(toolArguments(call.args)), TOOL_INPUT_CAP), + })), + }, + ], + }), + ); +} + +/** Reads the tail of the transcript at the named child's own unlinked profile path. */ +async function readChildTranscript( + profileRoot: string, + child: InvokeRecord["children"][number], + prompt: string, + byteCap: number, +) { + const expected = ( + await antigravityManagedPath( + profileRoot, + [ + "antigravity-acp", + "brain", + child.conversationId, + ".system_generated", + "logs", + "transcript.jsonl", + ], + "file", + ) + )?.path; + if (expected === undefined) return undefined; + let named: string; + try { + named = NodeURL.fileURLToPath(child.logUri); + } catch { + return undefined; + } + if ((await NodeFSP.realpath(named).catch(() => undefined)) !== expected) return undefined; + const handle = await NodeFSP.open( + expected, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NOFOLLOW ?? 0), + ); + try { + const stat = await handle.stat(); + if (!stat.isFile()) return undefined; + const length = Math.min(stat.size, byteCap); + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, stat.size - length); + const truncated = stat.size > length; + let contents = buffer.subarray(0, bytesRead).toString("utf8"); + // A tail read starts mid-line. + if (truncated) contents = contents.slice(contents.indexOf("\n") + 1); + return { steps: transcriptSteps(contents, prompt), truncated }; + } finally { + await handle.close(); + } +} + +class AntigravityChildWorkReadError extends Schema.TaggedError()( + "AntigravityChildWorkReadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not read Antigravity subagent records."; + } +} + +/** Children a subagent tool call launched, proven by the parent's own session record. */ +export const readAntigravityChildWork = Effect.fn("readAntigravityChildWork")(function* (input: { + readonly profileDirectory: string; + readonly rootSessionId: string; + readonly nativeTaskId: string; + readonly launch: BatchLaunch; + /** The thread's other Antigravity batches from this instance. */ + readonly siblings: ReadonlyArray; +}) { + if (!NATIVE_ID.test(input.rootSessionId)) return []; + return yield* Effect.tryPromise({ + try: async (): Promise> => { + const profileRoot = await antigravityProfileRoot(input.profileDirectory); + const databasePath = + profileRoot === undefined + ? undefined + : ( + await antigravityManagedPath( + profileRoot, + ["antigravity-acp", "conversations", `${input.rootSessionId}.db`], + "file", + ) + )?.path; + if (profileRoot === undefined || databasePath === undefined) return []; + const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + let record: InvokeRecord | undefined; + try { + record = findInvokeRecord(database, input); + } finally { + database.close(); + } + if (record === undefined) return []; + const { launches, children } = record; + // One budget for the whole batch, so the response stays small however many children ran. + const byteCap = Math.min( + TRANSCRIPT_BYTE_CAP, + Math.floor(BATCH_TRANSCRIPT_BYTES / Math.max(1, children.length)), + ); + return Promise.all( + children.map(async (child, index) => { + const transcript = await readChildTranscript( + profileRoot, + child, + launches[index]?.prompt ?? "", + byteCap, + ).catch(() => undefined); + return { + conversationId: child.conversationId, + role: launches[index]?.role ?? "", + prompt: launches[index]?.prompt ?? "", + steps: transcript?.steps ?? null, + truncated: transcript?.truncated ?? false, + }; + }), + ); + }, + catch: (cause) => new AntigravityChildWorkReadError({ cause }), + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityEditLocation.test.ts b/apps/server/src/provider/acp/AntigravityEditLocation.test.ts new file mode 100644 index 0000000000000..23f04f011fd25 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityEditLocation.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ANTIGRAVITY_EDIT_DIFF_OMITTED, antigravityEditPatch } from "./AntigravityEditLocation.ts"; + +const path = "/workspace/app.ts"; +const file = Array.from({ length: 20 }, (_, i) => + i === 9 + ? "const target = 10;" + : i === 14 + ? "const other = 15;" + : `const line${i + 1} = ${i + 1};`, +).join("\n"); +const replace = { TargetContent: "const target = 10;", ReplacementContent: "const target = 100;" }; +const header = `--- ${path}\n+++ ${path}\n`; + +describe("antigravityEditPatch", () => { + it("finds the target inside the announced search range, in either argument casing", () => { + const expected = `${header}@@ -10,1 +10,1 @@\n-const target = 10;\n+const target = 100;\n`; + const readPreEditFile = () => file; + for (const rawInput of [ + { ...replace, StartLine: 5, EndLine: 20 }, + { + target_content: replace.TargetContent, + replacement_content: replace.ReplacementContent, + start_line: 5, + end_line: 20, + }, + { + replacement_chunks: [ + { + target_content: replace.TargetContent, + replacement_content: replace.ReplacementContent, + start_line: 5, + end_line: 20, + }, + ], + }, + // Like the runtime, a present PascalCase value wins, and a 0 line means unspecified. + { ...replace, target_content: "const other = 15;", StartLine: 5, EndLine: 0 }, + ]) { + expect(antigravityEditPatch({ path, rawInput, readPreEditFile })).toBe(expected); + } + for (const rawInput of [replace, { ReplacementChunks: [replace] }]) { + expect( + antigravityEditPatch({ path, rawInput, locations: [{ path, line: 5 }], readPreEditFile }), + ).toBe(expected); + } + // A hunk replaces whole lines, so a match inside a line is not a placement. + expect( + antigravityEditPatch({ + path, + rawInput: { TargetContent: "10", ReplacementContent: "100" }, + readPreEditFile, + }), + ).toMatch(/ snippet, line unknown$/m); + // The range start is not the target's line: without the file, or outside the range, it stays unplaced. + const snippet = `${header}@@ -1,1 +1,1 @@ snippet, line unknown\n-const target = 10;\n+const target = 100;\n`; + expect( + antigravityEditPatch({ path, rawInput: { ...replace, StartLine: 5, EndLine: 20 } }), + ).toBe(snippet); + expect( + antigravityEditPatch({ + path, + rawInput: { ...replace, StartLine: 11, EndLine: 20 }, + readPreEditFile, + }), + ).toBe(snippet); + }); + + it("finds an unannounced snippet in the pre-edit file", () => { + const expected = `${header}@@ -10,1 +10,1 @@\n-const target = 10;\n+const target = 100;\n`; + expect(antigravityEditPatch({ path, rawInput: replace, readPreEditFile: () => file })).toBe( + expected, + ); + const crlf = file.replaceAll("\n", "\r\n"); + expect(antigravityEditPatch({ path, rawInput: replace, readPreEditFile: () => crlf })).toBe( + expected, + ); + // A target written with CRLF previews the same lines, without carriage returns. + expect( + antigravityEditPatch({ + path, + rawInput: { + TargetContent: "const target = 10;\r\n", + ReplacementContent: "const target = 100;\r\n", + }, + readPreEditFile: () => crlf, + }), + ).toBe(expected); + // A change to the line ending alone has no LF hunk to place. + const endingOnly = { + TargetContent: "const target = 10;\r\n", + ReplacementContent: "const target = 10;\n", + }; + for (const readPreEditFile of [() => crlf, undefined]) { + expect(antigravityEditPatch({ path, rawInput: endingOnly, readPreEditFile })).toBe( + ANTIGRAVITY_EDIT_DIFF_OMITTED, + ); + } + // A multi-line target written with LF still finds its lines in a CRLF file. + expect( + antigravityEditPatch({ + path, + rawInput: { + TargetContent: "const line9 = 9;\nconst target = 10;", + ReplacementContent: "const line9 = 9;\nconst target = 100;", + }, + readPreEditFile: () => crlf, + }), + ).toMatch(/^@@ -9,2 \+9,2 @@$/m); + }); + + it("omits the preview when a chunk adds or removes a line break at its end", () => { + // Whether that splits or joins lines depends on what follows the match. + for (const [TargetContent, ReplacementContent] of [ + ["const line3 = 3;", "x\n"], + ["const line3 = 3;\n", "x"], + ["const line20 = 20;", "x\n"], + // Clearing the last line of a file with no final newline removes the line. + ["const line20 = 20;", ""], + ]) { + expect( + antigravityEditPatch({ + path, + rawInput: { TargetContent, ReplacementContent }, + readPreEditFile: () => file, + }), + ).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + } + }); + + it("labels repeated or unmatched snippets instead of placing them at line 1", () => { + const snippet = `${header}@@ -1,1 +1,1 @@ snippet, line unknown\n-const target = 10;\n+const target = 100;\n`; + for (const readPreEditFile of [ + () => `${file}\n${file}`, + () => "unrelated\n", + () => undefined, + undefined, + ]) { + expect(antigravityEditPatch({ path, rawInput: replace, readPreEditFile })).toBe(snippet); + } + }); + + it("places each replacement chunk and shifts later chunks by earlier line changes", () => { + const patch = antigravityEditPatch({ + path, + rawInput: { + ReplacementChunks: [ + { + TargetContent: "const other = 15;\n", + ReplacementContent: "const other = 150;\n", + StartLine: 15, + EndLine: 15, + }, + { + TargetContent: "const target = 10;\n", + ReplacementContent: "const target = 100;\nconst added = 1;\n", + StartLine: 10, + EndLine: 10, + }, + ], + }, + readPreEditFile: () => file, + }); + expect(patch).toBe( + [ + header.trimEnd(), + "@@ -10,1 +10,2 @@", + "-const target = 10;", + "+const target = 100;", + "+const added = 1;", + "@@ -15,1 +16,1 @@", + "-const other = 15;", + "+const other = 150;", + "", + ].join("\n"), + ); + }); + + it("omits the preview when only some chunks can be placed", () => { + const patch = antigravityEditPatch({ + path, + rawInput: { + ReplacementChunks: [ + { + TargetContent: "const target = 10;\n", + ReplacementContent: "const target = 100;\n", + StartLine: 10, + EndLine: 10, + }, + { TargetContent: "missing", ReplacementContent: "still missing" }, + ], + }, + readPreEditFile: () => file, + }); + expect(patch).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + }); + + it("leaves whole-file writes and unknown inputs to the generic diff", () => { + expect(antigravityEditPatch({ path, rawInput: { CodeContent: "x" } })).toBeUndefined(); + expect(antigravityEditPatch({ path, rawInput: undefined })).toBeUndefined(); + }); + + it("omits the preview once an edit exceeds the shared diff budget", () => { + const lines = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix} ${index} ${"x".repeat(8)}`).join("\n"); + // A 7,000-line wholesale replacement, as one chunk. + expect( + antigravityEditPatch({ + path, + rawInput: { + TargetContent: lines("old", 7_000), + ReplacementContent: lines("new", 7_000), + StartLine: 1, + }, + }), + ).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + // Chunks that each fit, but not together. + const chunk = (start: number) => ({ + TargetContent: lines(`old-${start}`, 300), + ReplacementContent: lines(`new-${start}`, 300), + StartLine: start, + }); + expect( + antigravityEditPatch({ + path, + rawInput: { ReplacementChunks: [chunk(1), chunk(1_000)] }, + }), + ).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + // Text over the size limit is not diffed at all. + expect( + antigravityEditPatch({ + path, + rawInput: { + TargetContent: "a".repeat(200_000), + ReplacementContent: "b".repeat(100_000), + StartLine: 1, + }, + }), + ).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + expect(antigravityEditPatch({ path, rawInput: { ReplacementChunks: [chunk(1)] } })).toMatch( + /^@@ -1,300 \+1,300 @@/m, + ); + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityEditLocation.ts b/apps/server/src/provider/acp/AntigravityEditLocation.ts new file mode 100644 index 0000000000000..ff16dd014c97a --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityEditLocation.ts @@ -0,0 +1,226 @@ +// @effect-diagnostics nodeBuiltinImport:off - the tool-call normalizer is synchronous +import * as NodeFS from "node:fs"; + +import { PositiveInt } from "@t3tools/contracts"; +import { FILE_HEADERS_ONLY, formatPatch, structuredPatch, type StructuredPatchHunk } from "diff"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const PRE_EDIT_FILE_LIMIT = 1024 * 1024; +// The live preview runs synchronously. One budget covers every chunk in a call: the generic +// ACP formatter's edit-distance limit, plus a size limit that skips diffing entirely. +const EDIT_DIFF_BUDGET = 1_000; +const EDIT_TEXT_BUDGET = 256 * 1024; + +/** The edit is too large to preview cheaply; the turn's checkpoint diff still shows it. */ +export const ANTIGRAVITY_EDIT_DIFF_OMITTED = Symbol("AntigravityEditDiffOmitted"); +const SNIPPET_LABEL = " snippet, line unknown"; + +/** True when no chunk could be placed; a patch never mixes placed and unplaced chunks. */ +export function isAntigravitySnippetPatch(patch: string): boolean { + return /^@@ .* @@.*$/m.exec(patch)?.[0].endsWith(SNIPPET_LABEL) === true; +} + +// The runtime reads a missing, 0 or invalid line as unspecified. +const Line = Schema.optional(Schema.Unknown); +const decodeLine = Schema.decodeUnknownOption(PositiveInt); +const lineOf = (value: unknown) => Option.getOrUndefined(decodeLine(value)); +const ReplacementChunk = Schema.Struct({ + TargetContent: Schema.String, + ReplacementContent: Schema.String, + StartLine: Line, + EndLine: Line, +}); +// replace_file_content sends one chunk at the top level; multi_replace_file_content sends a list. +const decodeEditInput = Schema.decodeUnknownOption( + Schema.Union([ + Schema.Struct({ ReplacementChunks: Schema.NonEmptyArray(ReplacementChunk) }), + ReplacementChunk, + ]), +); +const decodeLocations = Schema.decodeUnknownOption(Schema.Array(Schema.Struct({ line: Line }))); +// The runtime accepts edit arguments in either casing. +const PASCAL_KEYS: Readonly> = { + replacement_chunks: "ReplacementChunks", + target_content: "TargetContent", + replacement_content: "ReplacementContent", + start_line: "StartLine", + end_line: "EndLine", +}; +const pascalKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(pascalKeys); + if (typeof value !== "object" || value === null) return value; + const fields: Record = value as Record; + const result: Record = {}; + for (const [key, entry] of Object.entries(fields)) { + const pascal = PASCAL_KEYS[key]; + // Like the runtime, a present PascalCase value wins over its snake_case alias. + if (pascal === undefined) result[key] ??= pascalKeys(entry); + else if (fields[pascal] == null) result[pascal] = pascalKeys(entry); + } + return result; +}; + +/** Reads a small file for snippet matching; edits to large or unreadable files stay unlocated. */ +export function readAntigravityPreEditFile(path: string): string | undefined { + try { + const stat = NodeFS.statSync(path); + return stat.isFile() && stat.size <= PRE_EDIT_FILE_LIMIT + ? NodeFS.readFileSync(path, "utf8") + : undefined; + } catch { + return undefined; + } +} + +/** The line of the snippet's only match starting within `from`..`to`, if it spans whole lines. */ +function uniqueStartLine( + text: string | undefined, + snippet: string, + from = 1, + to = Number.POSITIVE_INFINITY, +): number | undefined { + if (text === undefined || snippet === "") return undefined; + let found: { readonly line: number; readonly whole: boolean } | undefined; + let line = 1; + let lineStart = 0; + let nextBreak = text.indexOf("\n"); + for (let index = text.indexOf(snippet); index !== -1; index = text.indexOf(snippet, index + 1)) { + while (nextBreak !== -1 && nextBreak < index) { + line++; + lineStart = nextBreak + 1; + nextBreak = text.indexOf("\n", lineStart); + } + if (line > to) break; + if (line < from) continue; + if (found !== undefined) return undefined; + const end = index + snippet.length; + // A hunk replaces whole lines. + const whole = + index === lineStart && (end === text.length || snippet.endsWith("\n") || text[end] === "\n"); + found = { line, whole }; + } + return found?.whole === true ? found.line : undefined; +} + +function snippetHunks( + oldText: string, + newText: string, + maxEditLength: number, +): StructuredPatchHunk[] | undefined { + const before = oldText.replaceAll("\r\n", "\n"); + const after = newText.replaceAll("\r\n", "\n"); + // A target without its newline keeps the line's own newline, on both sides. + const eol = before.endsWith("\n") ? "" : "\n"; + return structuredPatch("", "", before + eol, after + eol, undefined, undefined, { + maxEditLength, + })?.hunks; +} + +const changedLines = (hunks: ReadonlyArray) => + hunks.reduce( + (total, hunk) => + total + hunk.lines.filter((line) => line.startsWith("+") || line.startsWith("-")).length, + 0, + ); + +/** + * Antigravity reports replacements as bare old/new snippets. `StartLine` only starts the + * runtime's search range, so a chunk is placed at its unique match in the pre-edit file within + * that range. All-unplaced chunks get hunks labeled as snippets; a mix is omitted. + */ +export function antigravityEditPatch(input: { + readonly path: string; + readonly rawInput: unknown; + readonly locations?: unknown; + /** Pass only while the edit is pending, when the file still holds the old text. */ + readonly readPreEditFile?: ((path: string) => string | undefined) | undefined; +}): string | typeof ANTIGRAVITY_EDIT_DIFF_OMITTED | undefined { + const edit = Option.getOrUndefined(decodeEditInput(pascalKeys(input.rawInput))); + if (edit === undefined) return undefined; + const announced = lineOf(Option.getOrUndefined(decodeLocations(input.locations))?.[0]?.line); + const listed = "ReplacementChunks" in edit ? edit.ReplacementChunks : [edit]; + const chunks = listed.map((chunk) => ({ + ...chunk, + StartLine: lineOf(chunk.StartLine) ?? (listed.length === 1 ? announced : undefined), + EndLine: lineOf(chunk.EndLine), + })); + let file: string | undefined; + let fileRead = false; + const textSize = chunks.reduce( + (total, chunk) => total + chunk.TargetContent.length + chunk.ReplacementContent.length, + 0, + ); + if (textSize > EDIT_TEXT_BUDGET) return ANTIGRAVITY_EDIT_DIFF_OMITTED; + let budget = EDIT_DIFF_BUDGET; + const placed: Array<{ readonly start: number; readonly hunks: StructuredPatchHunk[] }> = []; + const snippets: StructuredPatchHunk[] = []; + for (const chunk of chunks) { + // Whether a changed final line break, or a cleared line, splits, joins or removes lines + // depends on what follows the match. Only a whole-line delete is certain. + const targetEndsLine = chunk.TargetContent.endsWith("\n"); + if ( + chunk.ReplacementContent === "" + ? !targetEndsLine + : targetEndsLine !== chunk.ReplacementContent.endsWith("\n") + ) + return ANTIGRAVITY_EDIT_DIFF_OMITTED; + const hunks = snippetHunks(chunk.TargetContent, chunk.ReplacementContent, budget); + // A change to the line ending alone leaves no LF hunk to show. + if ( + hunks === undefined || + (hunks.length === 0 && chunk.TargetContent !== chunk.ReplacementContent) + ) + return ANTIGRAVITY_EDIT_DIFF_OMITTED; + budget -= changedLines(hunks); + if (budget < 0) return ANTIGRAVITY_EDIT_DIFF_OMITTED; + let start: number | undefined; + if (input.readPreEditFile !== undefined) { + // Line numbers do not depend on CRLF, so search with LF on both sides. + if (!fileRead) file = input.readPreEditFile(input.path)?.replaceAll("\r\n", "\n"); + fileRead = true; + start = uniqueStartLine( + file, + chunk.TargetContent.replaceAll("\r\n", "\n"), + chunk.StartLine, + chunk.EndLine, + ); + } + if (start === undefined) snippets.push(...hunks); + else placed.push({ start, hunks }); + } + // A placed hunk beside an unplaced one would still number the unplaced one from line 1. + if (placed.length > 0 && snippets.length > 0) return ANTIGRAVITY_EDIT_DIFF_OMITTED; + let shift = 0; + const placedHunks = placed + .toSorted((left, right) => left.start - right.start) + .flatMap(({ start, hunks }) => { + const shifted = hunks.map((hunk) => ({ + ...hunk, + oldStart: hunk.oldStart + start - 1, + newStart: hunk.newStart + start - 1 + shift, + })); + for (const hunk of hunks) shift += hunk.newLines - hunk.oldLines; + return shifted; + }); + const section = (hunks: StructuredPatchHunk[]) => + hunks.length === 0 + ? "" + : formatPatch( + { + oldFileName: input.path, + newFileName: input.path, + oldHeader: undefined, + newHeader: undefined, + hunks, + }, + FILE_HEADERS_ONLY, + ); + const patch = [ + section(placedHunks), + section(snippets).replace(/^@@ .* @@$/gm, `$&${SNIPPET_LABEL}`), + ] + .filter(Boolean) + .join("\n"); + return patch || undefined; +} diff --git a/apps/server/src/provider/acp/AntigravityProfilePaths.ts b/apps/server/src/provider/acp/AntigravityProfilePaths.ts new file mode 100644 index 0000000000000..eb5ef1fcac292 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityProfilePaths.ts @@ -0,0 +1,37 @@ +// @effect-diagnostics nodeBuiltinImport:off -- lstat proves no managed component is a link. +import type * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +/** + * Proves a native runtime entry is at its literal path below the canonical profile: every + * component exists with the expected type and none is a link. A link could point at another + * child's transcript or another session's database under the same profile. + */ +export async function antigravityManagedPath( + profileRoot: string, + segments: ReadonlyArray, + kind: "file" | "directory", +): Promise<{ readonly path: string; readonly info: NodeFS.Stats } | undefined> { + let current = profileRoot; + let info: NodeFS.Stats | undefined; + for (const [index, segment] of segments.entries()) { + current = NodePath.join(current, segment); + info = await NodeFSP.lstat(current).catch(() => undefined); + const expectFile = kind === "file" && index === segments.length - 1; + if ( + info === undefined || + info.isSymbolicLink() || + (expectFile ? !info.isFile() : !info.isDirectory()) + ) { + return undefined; + } + } + if (info === undefined) return undefined; + return (await NodeFSP.realpath(current).catch(() => undefined)) === current + ? { path: current, info } + : undefined; +} + +export const antigravityProfileRoot = (profileDirectory: string) => + NodeFSP.realpath(profileDirectory).catch(() => undefined); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 909de58cb7557..a2d7c86f4c2d3 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -19,6 +19,7 @@ import { mergeToolCallState, parseSessionUpdateEvent, } from "@t3tools/provider-acp/server/runtimeModel"; +import { acpToolCallDiffPatch } from "@t3tools/provider-acp/server/adapter"; const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); @@ -291,6 +292,135 @@ describe("Antigravity tool results", () => { expect(commandCompleted.update.rawOutput.formatted_output).toBe("after\n"); }); + it("keeps the captured edit location through approval and completion", () => { + const path = "/workspace/app.ts"; + const replace = { + TargetContent: "const target = 10;", + ReplacementContent: "const target = 100;", + TargetFile: path, + }; + const frame = (rawInput: Record, line?: number) => + ({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "edit-1", + title: "Run edit_file?", + kind: "edit", + status: "pending", + content: [ + { + type: "diff", + path, + oldText: replace.TargetContent, + newText: replace.ReplacementContent, + }, + ], + locations: [line === undefined ? { path } : { path, line }], + rawInput, + }, + }) satisfies EffectAcpSchema.SessionNotification; + const running = parseToolUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "edit-1", + kind: "edit", + status: "in_progress", + title: "Running edit_file", + locations: [{ path }], + rawInput: { file_path: path }, + }, + }).toolCall; + const completed = parseToolUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "edit-1", + status: "completed", + rawOutput: "Tool execution", + }, + }).toolCall; + const preEdit = Array.from({ length: 12 }, (_, i) => + i === 9 ? replace.TargetContent : `line ${i + 1}`, + ).join("\n"); + const postEdit = preEdit.replace(replace.TargetContent, replace.ReplacementContent); + for (const [pending, readFile] of [ + [frame({ ...replace, StartLine: 5, EndLine: 12 }, 5), () => preEdit], + [frame(replace), () => preEdit], + ] as const) { + let toolCall = normalizeAntigravityToolCall(parseToolUpdate(pending).toolCall, readFile); + for (const next of [running, completed]) { + toolCall = normalizeAntigravityToolCall(mergeToolCallState(toolCall, next), () => postEdit); + } + expect(toolCall.status).toBe("completed"); + expect(acpToolCallDiffPatch(toolCall.data.content)).toBe( + `--- ${path}\n+++ ${path}\n@@ -10,1 +10,1 @@\n-const target = 10;\n+const target = 100;\n`, + ); + } + // Full-access edits arrive path-only; no hunk is offered instead of the tool's status text. + let pathOnly = normalizeAntigravityToolCall( + parseToolUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "edit-2", + kind: "edit", + status: "in_progress", + title: "Running edit_file", + locations: [{ path }], + rawInput: { file_path: path }, + }, + }).toolCall, + ); + pathOnly = normalizeAntigravityToolCall(mergeToolCallState(pathOnly, completed)); + expect(pathOnly.data.content).toEqual([{ type: "diff", patch: { format: "omitted" } }]); + expect(acpToolCallDiffPatch(pathOnly.data.content)).toBeUndefined(); + // A captured patch that a later update's shared text budget cut or dropped is omitted. + for (const patch of [{ text: "[Earlier output truncated]\n\n@@ -80,1 +80,1 @@" }, {}]) { + const kept = normalizeAntigravityToolCall({ + toolCallId: "edit-cut", + kind: "edit", + status: "completed", + data: { content: [{ type: "diff", path, patch }] }, + }); + expect(kept.data.content).toEqual([{ type: "diff", path, patch: { format: "omitted" } }]); + } + // A whole-file write whose old text was cut to its tail must not show the cut as deletions. + const overwrite = normalizeAntigravityToolCall({ + toolCallId: "edit-overwrite", + kind: "edit", + status: "completed", + data: { + rawInput: { TargetFile: path, CodeContent: "one line\n" }, + content: [ + { + type: "diff", + path, + oldText: "[Earlier output truncated]\n\nrow 999\n", + newText: "one line\n", + }, + ], + }, + }); + expect(acpToolCallDiffPatch(overwrite.data.content)).toBeUndefined(); + // A patch too long to keep, or input cut to its tail, must not fall back to line-1 snippets. + for (const width of [60, 120]) { + const block = (value: string) => + Array.from({ length: 120 }, (_, i) => `${value} ${i} `.padEnd(width, "x")).join("\n"); + const long = { + TargetContent: block("old"), + ReplacementContent: block("new"), + TargetFile: path, + }; + const toolCall = normalizeAntigravityToolCall( + parseToolUpdate(frame({ ...long, StartLine: 80, EndLine: 199 }, 80)).toolCall, + ); + expect(toolCall.data.content).toMatchObject([{ patch: { format: "omitted" } }]); + expect(acpToolCallDiffPatch(toolCall.data.content)).toBeUndefined(); + } + }); + it.each([ { CommandLine: "pwd", Cwd: "/one" }, { CommandLine: "pwd", WorkingDirectory: "/one" }, diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index a91d45164caaf..5b74756a1de11 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -12,6 +12,13 @@ import * as AcpWireSchema from "effect-acp/schema-v1"; import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; +import { + ANTIGRAVITY_EDIT_DIFF_OMITTED, + antigravityEditPatch, + isAntigravitySnippetPatch, + readAntigravityPreEditFile, +} from "./AntigravityEditLocation.ts"; + const TOOL_TEXT_LIMIT = 8_000; const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; const QUESTION_LABEL_LIMIT = 512; @@ -163,6 +170,12 @@ function boundText(text: string, limit = TOOL_TEXT_LIMIT): string { : copyBoundedText(`${TOOL_TEXT_TRUNCATED}${text.slice(-limit)}`); } +/** Keeps the tail of long tool text, as every bounded tool payload does. */ +export { boundText as boundAntigravityToolText }; + +/** True when bounding cut some of this text. */ +export const isCutAntigravityToolText = (text: string) => text.includes(TOOL_TEXT_TRUNCATED); + interface ToolPayloadBudget { nodes: number; text: number; @@ -350,7 +363,58 @@ function localImagePath(imagePath: string | undefined): string | undefined { return /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path) ? undefined : path; } -export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToolCallState { +function hasTruncatedText(value: unknown): boolean { + if (typeof value === "string") return value.startsWith(TOOL_TEXT_TRUNCATED); + return Predicate.isObject(value) && Object.values(value).some(hasTruncatedText); +} + +/** The ACP adapter renders a diff's `patch.text` ahead of its bare old/new snippets. */ +function withAntigravityEditPatches( + content: ReadonlyArray, + data: Record, + readPreEditFile: ((path: string) => string | undefined) | undefined, +) { + return content.map((entry) => { + if (!Predicate.isObject(entry) || entry.type !== "diff" || typeof entry.path !== "string") + return entry; + // Keep the first patch: once the edit runs, the file no longer holds the old text. A later + // update's shared text budget can cut or drop it, and a cut patch would misplace the hunk. + if (entry.patch !== undefined) { + const patch: unknown = entry.patch; + const cut = + Predicate.isObject(patch) && + patch.format !== "omitted" && + (typeof patch.text !== "string" || patch.text.startsWith(TOOL_TEXT_TRUNCATED)); + return cut ? { ...entry, patch: { format: "omitted" } } : entry; + } + // Bounding keeps only the tail of long text, so a cut snippet cannot be located and a cut + // old or new text would render the cut as file content. + if ([data.rawInput, entry.oldText, entry.newText].some(hasTruncatedText)) + return { ...entry, patch: { format: "omitted" } }; + const text = antigravityEditPatch({ + path: entry.path, + rawInput: data.rawInput, + locations: data.locations, + readPreEditFile, + }); + // A truncated patch would no longer parse, and bare snippets would render at line 1. + if ( + text === ANTIGRAVITY_EDIT_DIFF_OMITTED || + (text !== undefined && text.length > TOOL_TEXT_LIMIT) + ) + return { ...entry, patch: { format: "omitted" } }; + if (text === undefined) return entry; + return { + ...entry, + patch: isAntigravitySnippetPatch(text) ? { format: "snippet", text } : { text }, + }; + }); +} + +export function normalizeAntigravityToolCall( + toolCall: AcpToolCallState, + readPreEditFile = readAntigravityPreEditFile, +): AcpToolCallState { const input = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawInput)); const output = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawOutput)); const nativeCommand = @@ -382,6 +446,17 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo if (command !== undefined) data.command = command; if (cwd !== undefined) data.cwd = cwd; if (imagePath !== undefined) data.imagePath = imagePath; + if (kind === "edit") { + const content = Array.isArray(data.content) ? data.content : []; + // Full-access edits report only a path; without a hunk the checkpoint diff is the review. + data.content = content.some((entry) => Predicate.isObject(entry) && entry.type === "diff") + ? withAntigravityEditPatches( + content, + data, + toolCall.status === "pending" ? readPreEditFile : undefined, + ) + : [...content, { type: "diff", patch: { format: "omitted" } }]; + } if (kind === "execute") { data.item = { ...(Predicate.isObject(data.item) ? data.item : {}), diff --git a/apps/server/src/provider/acp/AntigravityTurnOutcome.test.ts b/apps/server/src/provider/acp/AntigravityTurnOutcome.test.ts new file mode 100644 index 0000000000000..d3ca431c50794 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityTurnOutcome.test.ts @@ -0,0 +1,195 @@ +import * as NodeSqlite from "node:sqlite"; + +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 Path from "effect/Path"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { watchAntigravityPromptOutcome } from "./AntigravityTurnOutcome.ts"; + +const USER_INPUT = 14; +const PLANNER_RESPONSE = 15; +const TOOL = 8; +const ERROR_MESSAGE = 17; + +// Error-step payloads trimmed from Antigravity 1.4.5 against a local Gemini endpoint. +const errorPayload = (error: string) => + Buffer.concat([ + Buffer.from([0x0a, 0x24]), + Buffer.from( + `Agent execution terminated due to error.\u0012agent executor error: ${error}: ${error}\n(1) attached stack trace\n -- stack trace:`, + ), + ]); +// An unparsable reset timestamp must not hide the reset delay. +const QUOTA_ERROR = errorPayload( + "Error 429, Message: Quota exhausted for test model., Status: RESOURCE_EXHAUSTED, Details: [map[@type:type.googleapis.com/google.rpc.ErrorInfo domain:googleapis.com metadata:map[quotaResetDelay:3600s quotaResetTimeStamp:soon] reason:QUOTA_EXHAUSTED]]", +); +const INVALID_ARGUMENT_ERROR = errorPayload( + "Error 400, Message: Request contains an invalid argument., Status: INVALID_ARGUMENT, Details: []", +); +// The model's reply quoting the agent's quota notice is an ordinary planner response. +const QUOTED_NOTICE = Buffer.from( + "Usage Limit Reached\n\nYou have reached your current quota for this period. Your limit will reset in 1 hour.", +); + +describe("watchAntigravityPromptOutcome", () => { + it.effect("fails only turns whose own steps end in an upstream error", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profileDirectory = yield* fs.makeTempDirectoryScoped(); + const sessionId = "e6aba774-5472-4277-b6ee-27b2e05570a7"; + const conversations = path.join(profileDirectory, "antigravity-acp", "conversations"); + yield* fs.makeDirectory(conversations, { recursive: true }); + let nextIndex = 0; + const appendSteps = (steps: ReadonlyArray) => { + const database = new NodeSqlite.DatabaseSync(path.join(conversations, `${sessionId}.db`)); + try { + database.exec( + "CREATE TABLE IF NOT EXISTS steps (idx INTEGER PRIMARY KEY, step_type INTEGER, step_payload BLOB)", + ); + const insert = database.prepare("INSERT INTO steps VALUES (?, ?, ?)"); + for (const [type, payload] of steps) insert.run(nextIndex++, type, payload); + } finally { + database.close(); + } + }; + const turn = ( + steps: ReadonlyArray, + result: EffectAcpSchema.PromptResponse = { stopReason: "end_turn" }, + ) => + Effect.gen(function* () { + const check = yield* watchAntigravityPromptOutcome(profileDirectory, sessionId); + appendSteps(steps); + return yield* check(result); + }); + const input = Buffer.from("Run the fixture."); + const reply = Buffer.from("FIXTURE_OK"); + + // The first prompt creates the session log. + assert.isUndefined( + yield* turn([ + [USER_INPUT, input], + [PLANNER_RESPONSE, reply], + ]), + ); + assert.deepEqual( + yield* turn([ + [USER_INPUT, input], + [ERROR_MESSAGE, QUOTA_ERROR], + ]), + { + class: "usage_limit", + message: "You have reached your Antigravity quota for this period.", + code: "RESOURCE_EXHAUSTED", + retryable: true, + resetAt: "1970-01-01T01:00:00.000Z", + }, + ); + // The earlier error step is outside this turn. + assert.isUndefined( + yield* turn( + [ + [USER_INPUT, input], + [PLANNER_RESPONSE, QUOTED_NOTICE], + ], + { + stopReason: "end_turn", + usage: { inputTokens: 42, outputTokens: 3, totalTokens: 54 }, + }, + ), + ); + assert.deepEqual( + yield* turn([ + [USER_INPUT, input], + [ERROR_MESSAGE, INVALID_ARGUMENT_ERROR], + ]), + { + class: "provider_error", + message: "Request contains an invalid argument.", + code: "INVALID_ARGUMENT", + retryable: false, + }, + ); + // A completed tool keeps its usage, and the turn still fails. + assert.equal( + (yield* turn( + [ + [USER_INPUT, input], + [PLANNER_RESPONSE, reply], + [TOOL, reply], + [ERROR_MESSAGE, QUOTA_ERROR], + ], + { + stopReason: "end_turn", + usage: { inputTokens: 40, outputTokens: 5, totalTokens: 45 }, + }, + ))?.class, + "usage_limit", + ); + assert.isUndefined( + yield* turn([ + [USER_INPUT, input], + [PLANNER_RESPONSE, reply], + ]), + ); + // The agent maps the SDK's QUOTA_EXHAUSTED stop reason to `refusal`. + assert.deepEqual(yield* turn([], { stopReason: "refusal" }), { + class: "usage_limit", + message: "You have reached your Antigravity quota for this period.", + code: "QUOTA_EXHAUSTED", + retryable: true, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reads only this session's own unlinked log, and keeps a typed refusal", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profileDirectory = yield* fs.makeTempDirectoryScoped(); + const conversations = path.join(profileDirectory, "antigravity-acp", "conversations"); + yield* fs.makeDirectory(conversations, { recursive: true }); + const own = "e6aba774-5472-4277-b6ee-27b2e05570a7"; + const peer = "0d9c2b4e-1111-4222-8333-944455556666"; + const append = (sessionId: string, type: number, payload: Uint8Array) => { + const database = new NodeSqlite.DatabaseSync(path.join(conversations, `${sessionId}.db`)); + try { + database.exec( + "CREATE TABLE IF NOT EXISTS steps (idx INTEGER PRIMARY KEY, step_type INTEGER, step_payload BLOB)", + ); + database + .prepare("INSERT INTO steps (step_type, step_payload) VALUES (?, ?)") + .run(type, payload); + } finally { + database.close(); + } + }; + + // own.db links to a peer session; the peer's new error step is not this turn's. + append(peer, USER_INPUT, Buffer.from("peer")); + yield* fs.symlink( + path.join(conversations, `${peer}.db`), + path.join(conversations, `${own}.db`), + ); + const linked = yield* watchAntigravityPromptOutcome(profileDirectory, own); + append(peer, ERROR_MESSAGE, QUOTA_ERROR); + assert.isUndefined(yield* linked({ stopReason: "end_turn" })); + assert.equal((yield* linked({ stopReason: "refusal" }))?.class, "usage_limit"); + + // An unreadable log gives no evidence, but the agent's typed refusal still fails the turn. + yield* fs.remove(path.join(conversations, `${own}.db`)); + yield* fs.writeFileString(path.join(conversations, `${own}.db`), "not a database"); + const corrupt = yield* watchAntigravityPromptOutcome(profileDirectory, own); + assert.isUndefined(yield* corrupt({ stopReason: "end_turn" })); + assert.deepEqual(yield* corrupt({ stopReason: "refusal" }), { + class: "usage_limit", + message: "You have reached your Antigravity quota for this period.", + code: "QUOTA_EXHAUSTED", + retryable: true, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravityTurnOutcome.ts b/apps/server/src/provider/acp/AntigravityTurnOutcome.ts new file mode 100644 index 0000000000000..874c28eeb1537 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityTurnOutcome.ts @@ -0,0 +1,194 @@ +// @effect-diagnostics nodeBuiltinImport:off -- node:sqlite and lstat read the native session log. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; + +import type { OrchestrationV2ProviderFailure } from "@t3tools/contracts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { antigravityManagedPath, antigravityProfileRoot } from "./AntigravityProfilePaths.ts"; + +// CORTEX_STEP_TYPE_ERROR_MESSAGE in the native trajectory. +const ERROR_STEP_TYPE = 17; +// The harness error text leads the payload; a Go stack trace follows it. +const MAX_ERROR_PAYLOAD_BYTES = 16_384; +// The Go harness's rendering of a Google API error, as the agent's api_errors.py reads it. +const API_ERROR = /Error (\d{3}), Message: (.+?), Status: ([A-Z_]+)/s; +const QUOTA_EXHAUSTED = /\breason:QUOTA_EXHAUSTED\b/; +const RESET_TIMESTAMP = /\bquotaResetTimeStamp:([^\s\]]+)/; +const RESET_DELAY = /\bquotaResetDelay:((?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|h|m|s))+)(?=[\s\]])/; +const GO_DURATION_PART = /(\d+(?:\.\d+)?)(ns|us|µs|μs|ms|h|m|s)/g; +const GO_DURATION_UNIT_MS: Record = { + ns: 1e-6, + us: 1e-3, + µs: 1e-3, + μs: 1e-3, + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, +}; +const NATIVE_SESSION_ID = /^[\w-]+$/; + +interface LastStep { + readonly type: number; + readonly payload: Uint8Array | null; +} + +const readSteps = (file: string, read: (database: NodeSqlite.DatabaseSync) => A) => + Effect.try(() => { + const database = new NodeSqlite.DatabaseSync(file, { readOnly: true }); + try { + database.exec("PRAGMA busy_timeout = 100"); + const hasSteps = + database + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'steps'") + .get() !== undefined; + return hasSteps ? read(database) : undefined; + } finally { + database.close(); + } + }); + +const lastStepIndex = (database: NodeSqlite.DatabaseSync) => { + const index = database.prepare("SELECT max(idx) AS idx FROM steps").get()?.idx; + return typeof index === "number" ? index : -1; +}; + +const lastStepAfter = + (index: number) => + (database: NodeSqlite.DatabaseSync): LastStep | undefined => { + const row = database + .prepare( + `SELECT step_type AS type, substr(step_payload, 1, ?) AS payload + FROM steps WHERE idx > ? ORDER BY idx DESC LIMIT 1`, + ) + .get(MAX_ERROR_PAYLOAD_BYTES, index); + if (typeof row?.type !== "number") return undefined; + return { type: row.type, payload: row.payload instanceof Uint8Array ? row.payload : null }; + }; + +function resetAtFrom(text: string, now: DateTime.Utc): string | undefined { + // makeProviderFailure drops a timestamp that does not parse, so fall back to the delay. + const timestamp = RESET_TIMESTAMP.exec(text)?.[1]; + if (timestamp !== undefined && Option.isSome(DateTime.make(timestamp))) return timestamp; + const delay = RESET_DELAY.exec(text)?.[1]; + if (delay === undefined) return undefined; + let delayMs = 0; + for (const [, amount, unit] of delay.matchAll(GO_DURATION_PART)) { + delayMs += Number(amount) * (GO_DURATION_UNIT_MS[unit!] ?? 0); + } + return delayMs > 0 ? DateTime.formatIso(DateTime.add(now, { milliseconds: delayMs })) : undefined; +} + +function turnFailure( + stopReason: EffectAcpSchema.StopReason, + step: LastStep | undefined, + now: DateTime.Utc, +): OrchestrationV2ProviderFailure | undefined { + const errorText = + step?.type === ERROR_STEP_TYPE + ? new TextDecoder().decode(step.payload ?? new Uint8Array()) + : undefined; + // The agent maps only the SDK's QUOTA_EXHAUSTED stop reason to `refusal`. + if (errorText === undefined && stopReason !== "refusal") return undefined; + const text = errorText ?? ""; + const apiError = API_ERROR.exec(text); + if (stopReason === "refusal" || QUOTA_EXHAUSTED.test(text)) { + return makeProviderFailure({ + class: "usage_limit", + message: "You have reached your Antigravity quota for this period.", + code: apiError?.[3] ?? "QUOTA_EXHAUSTED", + retryable: true, + resetAt: resetAtFrom(text, now) ?? null, + }); + } + if (apiError === null) { + return makeProviderFailure({ + class: "provider_error", + message: "Antigravity stopped this turn because of an error.", + }); + } + const [, httpStatus, message, status] = apiError; + const rateLimited = httpStatus === "429" || status === "RESOURCE_EXHAUSTED"; + return makeProviderFailure({ + class: rateLimited ? "usage_limit" : "provider_error", + message: message!.trim(), + code: status ?? null, + retryable: rateLimited || Number(httpStatus) >= 500, + }); +} + +const conversationSegments = ["antigravity-acp", "conversations"]; + +/** + * Antigravity reports an upstream API failure as a chat message and `end_turn`. Its + * session log ends that turn with an error step, so the check reads only steps the + * current prompt added, read-only, from this session's own unlinked database. Without + * that evidence a typed `refusal` still fails the turn. + */ +export const watchAntigravityPromptOutcome = Effect.fn("AntigravityTurnOutcome.watch")(function* ( + profileDirectory: string, + sessionId: string, +) { + const typedOnly = (result: EffectAcpSchema.PromptResponse) => + DateTime.now.pipe(Effect.map((now) => turnFailure(result.stopReason, undefined, now))); + if (!NATIVE_SESSION_ID.test(sessionId)) return typedOnly; + const segments = [...conversationSegments, `${sessionId}.db`]; + const before = yield* Effect.tryPromise(async () => { + const root = await antigravityProfileRoot(profileDirectory); + if (root === undefined) return undefined; + if ((await antigravityManagedPath(root, conversationSegments, "directory")) === undefined) { + return undefined; + } + const database = await antigravityManagedPath(root, segments, "file"); + // A path that exists but fails the proof is a link or other foreign entry. + if (database === undefined) { + const present = await NodeFSP.lstat(NodePath.join(root, ...segments)).then( + () => true, + () => false, + ); + return present ? undefined : { root, identity: undefined, startIndex: -1 }; + } + return { root, identity: database.info, startIndex: undefined }; + }).pipe(Effect.orElseSucceed(() => undefined)); + if (before === undefined) { + yield* Effect.logWarning("The Antigravity session log is not this session's own file."); + return typedOnly; + } + const startIndex = + before.startIndex ?? + (yield* readSteps(NodePath.join(before.root, ...segments), lastStepIndex).pipe( + Effect.map((index) => index ?? -1), + Effect.orElseSucceed(() => undefined), + )); + if (startIndex === undefined) { + yield* Effect.logWarning("Could not read the Antigravity session log before the prompt."); + return typedOnly; + } + return Effect.fn("AntigravityTurnOutcome.check")(function* ( + result: EffectAcpSchema.PromptResponse, + ) { + // The same file as before the prompt, or one this prompt created, still unlinked. + const after = yield* Effect.tryPromise(() => + antigravityManagedPath(before.root, segments, "file"), + ).pipe(Effect.orElseSucceed(() => undefined)); + const sameFile = + after !== undefined && + (before.identity === undefined || + (after.info.dev === before.identity.dev && after.info.ino === before.identity.ino)); + const step = sameFile + ? yield* readSteps(after.path, lastStepAfter(startIndex)).pipe( + Effect.tapError(() => + Effect.logWarning("Could not read the Antigravity session log after the prompt."), + ), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + return turnFailure(result.stopReason, step, yield* DateTime.now); + }); +}); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index e2c3f303962f0..c43141c70cfe4 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -4,7 +4,7 @@ import * as NodeFSP from "node:fs/promises"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -179,9 +179,9 @@ export interface AntigravityInstanceDirectories { export const resolveAntigravityInstanceDirectories = Effect.fn( "resolveAntigravityInstanceDirectories", )(function* (stateDir: string, instanceId: ProviderInstanceId) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const path = yield* Path.Path; - const key = Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(instanceId))); + const key = Hex.encode(yield* digest.digest(Digest.sha256, new TextEncoder().encode(instanceId))); return { profile: path.join(stateDir, "providers", "antigravity", key), runtimeTemp: path.join(stateDir, "antigravity-tmp", key.slice(0, 12)), diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index 6285f344db8f3..255a92761994a 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,3 +1,5 @@ +// Hashes and sizes match the official ACP registry release. Like 1.4.2 through 1.4.4, it writes a +// run-workflow skill into the workspace unless T3 disables the tool (antigravitySessionMeta). const ANTIGRAVITY_RELEASE_VERSION = "1.4.5"; export interface AntigravityReleaseAsset { diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 3eb366f0dba3a..612ea11efcefa 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -1,4 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { describe, expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -195,7 +197,7 @@ describe("Antigravity subscription limits", () => { }); expect(byId.get("gemini-5h")?.resetsAt).toBe("2026-10-21T13:29:53.000Z"); expect(byId.get("gemini-weekly")?.resetsAt).toBe("2026-10-28T08:29:53.000Z"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: measure the tolerance from the summary request, after auth. @@ -216,7 +218,7 @@ describe("Antigravity subscription limits", () => { }, }); expect((yield* probe).windows[0]?.resetsAt).toBeUndefined(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: keep descriptions, measured resets, and spent buckets. @@ -268,7 +270,7 @@ describe("Antigravity subscription limits", () => { { id: "spent", resetsAt: "2026-10-21T15:16:04.000Z" }, { id: "distant", resetsAt: "2026-10-28T12:16:04.000Z" }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: the 60-second tolerance applies to both known window lengths. @@ -325,7 +327,7 @@ describe("Antigravity subscription limits", () => { { id: "weekly-before", resetsAt: "2026-10-28T10:15:03.000Z" }, { id: "weekly-edge", resetsAt: undefined }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: the existing endpoint merge keeps the most-used reading. @@ -386,7 +388,7 @@ describe("Antigravity subscription limits", () => { resetsAt: "2026-10-27T10:16:04.000Z", }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: model quotas have no evidence for the summary-only rule. @@ -414,7 +416,7 @@ describe("Antigravity subscription limits", () => { { id: "gemini-weekly", resetsAt: "2026-10-28T10:16:04.000Z", windowDurationMins: 10080 }, { id: "gemini-quota", resetsAt: "2026-10-21T15:16:04.000Z", windowDurationMins: undefined }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reports separate Gemini and shared Claude/GPT five-hour and weekly limits", () => @@ -441,7 +443,7 @@ describe("Antigravity subscription limits", () => { expect( requests.filter((request) => request.url.endsWith(":retrieveUserQuotaSummary")), ).toHaveLength(4); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("merges quota groups that only one endpoint reports", () => @@ -473,7 +475,7 @@ describe("Antigravity subscription limits", () => { expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(70); expect(byId.get("3p-weekly")?.usedPercent).toBe(100); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("labels Argon's weekly quota apart from the default Gemini weekly quota", () => @@ -495,7 +497,7 @@ describe("Antigravity subscription limits", () => { const byId = new Map(limits.windows.map((window) => [window.id, window])); expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); expect(byId.get("gemini-argon-wtu-weekly")?.label).toBe("Gemini 4 Argon · Weekly"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("keeps one endpoint's quotas when the other fails", () => @@ -506,7 +508,7 @@ describe("Antigravity subscription limits", () => { const limits = yield* probe; expect(limits.unavailable).toBeUndefined(); expect(limits.windows).toHaveLength(4); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("re-reads changed credentials and clears cached access after sign-out", () => @@ -525,7 +527,7 @@ describe("Antigravity subscription limits", () => { yield* clear; yield* probe; expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(4); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("names one Google account the same way from every sign-in", () => @@ -539,7 +541,7 @@ describe("Antigravity subscription limits", () => { expect(machineB.credentialFingerprint).toBe(machineA.credentialFingerprint); expect(otherAccount.credentialFingerprint).not.toBe(machineA.credentialFingerprint); expect(JSON.stringify(machineA)).not.toMatch(/google-user-1|someone@example.com/); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("falls back to model quotas without inventing a window duration", () => @@ -568,7 +570,7 @@ describe("Antigravity subscription limits", () => { (window) => window.kind === "other" && window.windowDurationMins === undefined, ), ).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("skips disabled and missing fractions and clamps valid percentages", () => @@ -591,7 +593,7 @@ describe("Antigravity subscription limits", () => { windowDurationMins: 300, }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("returns a safe failure when OAuth refresh fails", () => @@ -601,7 +603,7 @@ describe("Antigravity subscription limits", () => { expect(limits.windows).toEqual([]); expect(limits.unavailable?.reason).toBe("probeFailed"); expect(JSON.stringify(limits)).not.toMatch(/client-secret|refresh-a|access-token/); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("does not make network requests for API keys or missing sign-ins", () => @@ -611,6 +613,6 @@ describe("Antigravity subscription limits", () => { expect((yield* probe).unavailable?.reason).toBe("unsupported"); expect(requests).toEqual([]); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index f73fddb305962..5445c01432a2f 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -1,7 +1,7 @@ import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -188,7 +188,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json"); @@ -199,7 +199,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const sha256 = (text: string) => crypto - .digest("SHA-256", new TextEncoder().encode(text)) + .digest(Digest.sha256, new TextEncoder().encode(text)) .pipe( Effect.map((digest) => Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""), diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 76819ce997efe..342a6780e9ad0 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -15,6 +15,7 @@ import type { AnyProviderDriver, ProviderUsageReaderEnv, } from "@t3tools/provider-core/server/driver"; +import { KiroDriver, type KiroDriverEnv } from "@t3tools/provider-kiro/server"; export type BuiltInDriversEnv = | AcpRegistryDriverEnv @@ -24,6 +25,7 @@ export type BuiltInDriversEnv = | CursorDriverEnv | FxDriverEnv | GrokDriverEnv + | KiroDriverEnv | OpenCodeDriverEnv | PiDriverEnv | MuseDriverEnv; @@ -36,6 +38,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray=0.0.42", - "recommendedRange": "=1.4.5", + "recommendedRange": "=1.4.5 || =1.4.1", "ranges": [ { "range": "=1.4.5", "status": "supported" }, { "range": "=1.4.1", "status": "supported" }, @@ -83,6 +83,12 @@ { "range": "=1.1.1", "status": "supported" } ] }, + { + "driver": "kiro", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=2.27.0", + "ranges": [{ "range": ">=2.27.0", "status": "supported" }] + }, { "driver": "pi", "t3CodeRange": ">=0.0.46", diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 9421201a2e549..8d843851a85a0 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -152,7 +152,7 @@ describe("provider compatibility", () => { `agy_acp_server_${release.version}`, ); assert.strictEqual(advisory?.status, "supported"); - assert.strictEqual(advisory?.recommendedRange, `=${release.version}`); + assert.include(advisory?.recommendedRange ?? "", `=${release.version}`); } }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index a304294295a07..a6f807f9dfc4a 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -214,6 +214,7 @@ function makeRegistry( ); }, refreshWorkspaceSnapshot: () => Ref.get(providersRef), + invalidateModelCaches: () => Effect.void, getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/providerPoolSnapshot.test.ts b/apps/server/src/provider/providerPoolSnapshot.test.ts index ed1d904b3399f..5c71e39f18de9 100644 --- a/apps/server/src/provider/providerPoolSnapshot.test.ts +++ b/apps/server/src/provider/providerPoolSnapshot.test.ts @@ -58,8 +58,32 @@ describe("withProviderPools", () => { }); it("publishes one pool entry with the model intersection and stamps the members", () => { + const commonSkill = { name: "shared", path: "/workspace/shared/SKILL.md", enabled: true }; + const commonOption = { id: "fast", label: "Fast", type: "boolean" } as const; + const personalWorkspace = { + cwd: "/workspace", + checkedAt: "2026-10-19T00:00:00.000Z", + slashCommands: [{ name: "compact" }, { name: "personal" }], + skills: [commonSkill, { name: "personal", path: "/personal/SKILL.md", enabled: true }], + optionDescriptors: [commonOption], + }; + const workWorkspace = { + ...personalWorkspace, + checkedAt: "2026-10-19T00:00:01.000Z", + slashCommands: [{ name: "compact" }, { name: "work" }], + slashCommandsPending: true, + skills: [commonSkill], + optionDescriptors: [commonOption, { id: "work", label: "Work", type: "boolean" as const }], + }; const published = withProviderPools({ - providers: [personal, work, provider("codex", codex)], + providers: [ + { ...personal, workspaceSnapshots: [personalWorkspace] }, + { + ...work, + workspaceSnapshots: [workWorkspace, { ...workWorkspace, cwd: "/work-only" }], + }, + provider("codex", codex), + ], providerPools: pools({ claudeAgent: ["claudeAgent_work", "claudeAgent"] }), }); const pool = published.find((entry) => entry.instanceId === "pool_claudeAgent"); @@ -77,6 +101,16 @@ describe("withProviderPools", () => { [["opus", "Opus (work)"]], ); assert.deepEqual(pool?.slashCommands, [{ name: "compact" }]); + assert.deepEqual(pool?.workspaceSnapshots, [ + { + cwd: "/workspace", + checkedAt: personalWorkspace.checkedAt, + slashCommands: [{ name: "compact" }], + slashCommandsPending: true, + skills: [commonSkill], + optionDescriptors: [commonOption], + }, + ]); assert.deepEqual( published.map((entry) => [entry.instanceId, entry.pool]), [ @@ -135,13 +169,23 @@ describe("withProviderPools", () => { ); it("keeps the pool and member identities when only one account remains enabled", () => { - const providers = [personal, provider("claudeAgent_work", claude, { enabled: false })]; + const workspace = { + cwd: "/workspace", + checkedAt: personal.checkedAt, + slashCommands: [{ name: "compact" }], + skills: [], + }; + const providers = [ + { ...personal, workspaceSnapshots: [workspace] }, + provider("claudeAgent_work", claude, { enabled: false }), + ]; const published = withProviderPools({ providers, providerPools: pools({ claudeAgent: ["claudeAgent", "claudeAgent_work", "missing"] }), }); assert.equal(published.at(-1)?.instanceId, "pool_claudeAgent"); assert.equal(published.at(-1)?.status, "ready"); + assert.deepEqual(published.at(-1)?.workspaceSnapshots, [workspace]); assert.equal(published[1]?.pool?.role, "member"); }); diff --git a/apps/server/src/provider/providerPoolSnapshot.ts b/apps/server/src/provider/providerPoolSnapshot.ts index 01044f1961589..e9cb228d2ccd4 100644 --- a/apps/server/src/provider/providerPoolSnapshot.ts +++ b/apps/server/src/provider/providerPoolSnapshot.ts @@ -5,6 +5,7 @@ import { type ActiveProviderPool, type ProviderPoolSettings, type ServerProvider, + type ServerProviderWorkspaceSnapshot, } from "@t3tools/contracts"; const STATUS_RANK: Record = { @@ -59,6 +60,45 @@ const optionSignature = (model: ServerProvider["models"][number]): string => ), ); +const intersectWorkspaceSnapshots = ( + members: ReadonlyArray, +): ReadonlyArray => { + const enabledMembers = members.filter((member) => member.enabled); + const [first, ...rest] = enabledMembers; + return (first?.workspaceSnapshots ?? []).flatMap((snapshot) => { + const otherSnapshots = rest.map((member) => + member.workspaceSnapshots?.find((candidate) => candidate.cwd === snapshot.cwd), + ); + if (otherSnapshots.some((candidate) => candidate === undefined)) return []; + const snapshots = [snapshot, ...otherSnapshots.filter((candidate) => candidate !== undefined)]; + return [ + { + cwd: snapshot.cwd, + // A pool snapshot expires as soon as any contributing member's inventory expires. + checkedAt: snapshots.reduce((oldest, candidate) => + Date.parse(candidate.checkedAt) < Date.parse(oldest.checkedAt) ? candidate : oldest, + ).checkedAt, + slashCommands: intersectByName(snapshots.map((candidate) => candidate.slashCommands)), + skills: intersectByName(snapshots.map((candidate) => candidate.skills)), + ...(snapshots.some((candidate) => candidate.slashCommandsPending) + ? { slashCommandsPending: true } + : {}), + ...(snapshots.every((candidate) => candidate.optionDescriptors === undefined) + ? {} + : { + optionDescriptors: (snapshot.optionDescriptors ?? []).filter((descriptor) => + snapshots.every((candidate) => + candidate.optionDescriptors?.some( + (other) => JSON.stringify(other) === JSON.stringify(descriptor), + ), + ), + ), + }), + }, + ]; + }); +}; + function makePoolEntry( pool: ActiveProviderPool, first: ServerProvider, @@ -121,6 +161,7 @@ function makePoolEntry( models, slashCommands: intersectByName(catalogMembers.map((member) => member.slashCommands)), skills: intersectByName(catalogMembers.map((member) => member.skills)), + workspaceSnapshots: intersectWorkspaceSnapshots(catalogMembers), pool: { role: "pool", memberInstanceIds: pool.memberInstanceIds, diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 5e3f5463c45e9..6645b8ea6888f 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -35,6 +35,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ "opencode", "antigravity", "fx", + "kiro", ]; const driverRank = (driver: string): number => { diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index 189c43b54f7f6..153728bc8ae43 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -1,7 +1,7 @@ /** Instances sharing a login directory share credits; serialize redemptions on that directory. */ import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type * as PlatformError from "effect/PlatformError"; @@ -27,7 +27,7 @@ export class ResetCreditCoordinator extends Context.Service< /** @public */ export const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const statesRef = yield* Ref.make>(new Map()); // Create account locks atomically so concurrent first redemptions cannot use separate locks. @@ -54,7 +54,7 @@ export const make = Effect.gen(function* () { return yield* state.lock.withPermits(1)( Effect.gen(function* () { const existing = yield* Ref.get(state.pendingKey); - const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4()); + const idempotencyKey = existing ?? (yield* random.uuidV4()); yield* Ref.set(state.pendingKey, idempotencyKey); const outcome = yield* consume(idempotencyKey).pipe( Effect.tapError((error) => @@ -78,13 +78,10 @@ export const layerTest = Layer.effect( let counter = 0; return yield* make.pipe( Effect.provideService( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => { - counter += 1; - return new Uint8Array(size).fill(counter); - }, - digest: (_algorithm, data) => Effect.succeed(data), + SecureRandom.SecureRandom, + SecureRandom.make((size) => { + counter += 1; + return new Uint8Array(size).fill(counter); }), ), ); diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 7472fe2832d97..a205c73ab8113 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -17,6 +17,7 @@ const makeProviderRegistryMock = ( reloadSecretsAndRefresh: () => Effect.succeed(providers), refreshAfterUpdate: () => Effect.succeed(providers), refreshWorkspaceSnapshot: () => Effect.succeed(providers), + invalidateModelCaches: () => Effect.void, getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts index bb6eb38f16146..b73774700fcf8 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.test.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -1,13 +1,19 @@ import * as NodeV8 from "node:v8"; import * as NodeVM from "node:vm"; import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { PullRequestOperationError } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; +import * as TestClock from "effect/testing/TestClock"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; NodeV8.setFlagsFromString("--expose-gc"); @@ -16,7 +22,7 @@ const collectGarbage = NodeVM.runInNewContext("gc") as () => void; const cacheLayer = (directory: string) => PullRequestReadCache.make.pipe(Effect.provide(KeyValueStore.layerFileSystem(directory))); -it.layer(NodeServices.layer)("PR filesystem cache", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PR filesystem cache", (it) => { it.effect("clears in-flight reads before a new service can reuse them", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -46,6 +52,24 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { }), ); + it.effect("prunes expired entry files the store wrote", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const cache = yield* cacheLayer(directory); + yield* cache.get("summary", Effect.succeed("value")); + const [entry] = yield* fs.readDirectory(directory); + assert.isDefined(entry); + const info = yield* fs.stat(path.join(directory, entry)); + yield* TestClock.setTime( + Option.getOrThrow(info.mtime).getTime() + Duration.toMillis(Duration.days(2)), + ); + yield* PullRequestReadCache.pruneExpiredEntryFiles(directory); + assert.deepStrictEqual(yield* fs.readDirectory(directory), []); + }), + ); + it.effect("invalidates only the changed scope across restarts and coalesces its next reads", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 6ea0aea8f76b6..f3ec665ca9786 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -3,7 +3,8 @@ import * as Clock from "effect/Clock"; import * as Equal from "effect/Equal"; import * as Hash from "effect/Hash"; import { PullRequestOperationError, PullRequestUnavailableError } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Hex from "effect/encoding/Hex"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -25,9 +26,10 @@ import { detachStackFrame } from "../git/detachStackFrame.ts"; import { forkParked } from "../serverActivation.ts"; const CONCURRENT_READS = 512; -// Entry filenames have the store-id prefix; the scope revisions file does not. +// Entry filenames are the URI-encoded `:`; the scope revisions file has +// no store id. Effect before 4.1 wrote ``, still pruned here. const STORE_ID = "pr-v2"; -const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}[0-9a-f]{64}$`); +const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}(?:%3A)?[0-9a-f]{64}$`); /** * A file untouched for a day is long expired; the day only leaves slack for * clock changes. @@ -80,12 +82,13 @@ export class PullRequestReadCache extends Context.Service< export const make = Effect.gen(function* () { const backing = yield* KeyValueStore.KeyValueStore; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digester = yield* Digest.Digest; const clock = yield* Clock.Clock; let enabled = true; const lock = yield* Semaphore.make(CONCURRENT_READS); const digest = (key: string) => - crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.map(Hex.encode)); + digester.digest(Digest.sha256, new TextEncoder().encode(key)).pipe(Effect.map(Hex.encode)); const revisions = yield* Cache.makeWith( () => backing.get("revisions").pipe( @@ -155,7 +158,6 @@ export const make = Effect.gen(function* () { ).payload; }).pipe( Effect.catchTags({ - PlatformError: () => read, KeyValueStoreError: () => read, PersistenceError: () => read, SchemaError: () => read, @@ -170,7 +172,7 @@ export const make = Effect.gen(function* () { const next = Object.fromEntries( Object.entries(current).filter(([, value]) => value.expiresAt > now), ); - next[scope] = { revision: yield* crypto.randomUUIDv4(), expiresAt: now + 60_000 }; + next[scope] = { revision: yield* random.uuidV4(), expiresAt: now + 60_000 }; const encoded = yield* Schema.encodeEffect(revisionCodec)(next); yield* backing.set("revisions", encoded); yield* Cache.set(revisions, undefined, next); @@ -190,33 +192,33 @@ export const make = Effect.gen(function* () { * The persisted cache drops an expired entry only when it is read again, so * files for PRs nobody reopens would otherwise stay forever. */ -const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpiredEntryFiles")(function* ( - directory: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE); - const entries = (yield* fileSystem.readDirectory(directory)).filter((name) => - ENTRY_FILE_NAME.test(name), - ); - const [, failures] = yield* Effect.partition(entries, (name) => { - const entryPath = path.join(directory, name); - return fileSystem.stat(entryPath).pipe( - Effect.flatMap((info) => - Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) - ? fileSystem.remove(entryPath) - : Effect.void, - ), - Effect.catchReason("PlatformError", "NotFound", () => Effect.void), +export const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpiredEntryFiles")( + function* (directory: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE); + const entries = (yield* fileSystem.readDirectory(directory)).filter((name) => + ENTRY_FILE_NAME.test(name), ); - }); - if (failures.length > 0) { - yield* Effect.logWarning("Failed to prune some PR cache files", { - failed: failures.length, - cause: failures[0], + const [, failures] = yield* Effect.partition(entries, (name) => { + const entryPath = path.join(directory, name); + return fileSystem.stat(entryPath).pipe( + Effect.flatMap((info) => + Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) + ? fileSystem.remove(entryPath) + : Effect.void, + ), + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); }); - } -}); + if (failures.length > 0) { + yield* Effect.logWarning("Failed to prune some PR cache files", { + failed: failures.length, + cause: failures[0], + }); + } + }, +); export const layer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index a4d944a457286..4f567142fbed5 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Context from "effect/Context"; @@ -221,7 +222,7 @@ const layerBase = Layer.mergeAll( Layer.provideMerge(VcsProcess.layer), ), SqlitePersistence.layerMemory, -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); type Repositories = Effect.Success; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 8f57de29f79bd..e4c47df7411d7 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,4 +1,5 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import { assert, expect, it } from "@effect/vitest"; @@ -300,7 +301,7 @@ function makeService(input: { PullRequestFilesViewed.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( Layer.provide(KeyValueStore.layerMemory), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 4bbaa85fc3b16..87838fd5a51ed 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -190,6 +190,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), listActiveSubagentThreadIds: unused, + listAppOwnedChildThreadIds: unused, countThreads: unused, listActiveShellIndex: () => Effect.succeed([]), listOlderSettledThreads: unused, @@ -482,6 +483,113 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("publishes a cleared thread's tombstone once while it stays cleared", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + + for (let i = 0; i < 3; i++) { + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + } + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + }), + ); + + it.effect("does not alert an old completion after publishing its thread's tombstone", () => + Effect.gen(function* () { + const oldCompleted = shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T11:59:00.000Z"), + }); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set(currentShell, { + ...oldCompleted, + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state), + [null], + ); + + yield* Ref.set(currentShell, oldCompleted); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state), + [null], + ); + }), + ); + + it.effect("alerts a completion settled during its hold after an earlier tombstone", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); + const { relay, currentShell, publications } = yield* makeTestRelay(); + const settledAt = DateTime.makeUnsafe(NOW); + yield* Ref.set(currentShell, { + ...shell({ status: "completed" }), + settledOverride: "settled", + settledAt, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + + const completed = shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:06.000Z"), + }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + [null, "completed", null], + ); + }), + ); + it.effect("drops a held completion when a queued continuation hides the thread", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f3c7b893afa83..6a235911d4f91 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -23,7 +23,7 @@ import { } from "@t3tools/shared/relayJwt"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -113,6 +113,7 @@ function shouldPublishAgentAwarenessEvent( case "thread.unpinned": case "thread.pin-reordered": case "thread.active-reordered": + case "thread.tags-updated": case "thread.visited": case "thread.marked-unread": case "thread.runtime-mode-updated": @@ -164,6 +165,17 @@ function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): s return JSON.stringify(meaningfulState); } +const CLEARED_PUBLISH_IDENTITY = agentAwarenessPublishIdentity(null); + +// A published tombstone stays recorded so repeats are skipped, but it is not live state. +function hasLivePublishedState( + publishedStateByThread: ReadonlyMap, + threadId: ThreadId, +): boolean { + const identity = publishedStateByThread.get(threadId); + return identity !== undefined && identity !== CLEARED_PUBLISH_IDENTITY; +} + function resolveAgentActivityPublishingStartupState(input: { readonly relayConfigured: boolean; readonly publishEnabled: boolean; @@ -365,7 +377,7 @@ export const make = Effect.gen(function* () { const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const threads = yield* ThreadManagement.ThreadManagementService; const projects = yield* ProjectService.ProjectService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const scope = yield* Effect.scope; const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); const startedAt = (yield* DateTime.now).epochMilliseconds; @@ -478,7 +490,7 @@ export const make = Effect.gen(function* () { environmentId, threadId, state: input.state, - jti: yield* crypto.randomUUIDv4(), + jti: yield* random.uuidV4(), }); yield* Effect.logInfo("publishing agent activity for thread", { @@ -516,7 +528,7 @@ export const make = Effect.gen(function* () { if ( threadShell !== null && !isListedThreadLineage(threadShell.lineage) && - !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) + !hasLivePublishedState(yield* Ref.get(publishedStateByThreadRef), threadId) ) { // Subagents and side chats have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. return; @@ -537,11 +549,15 @@ export const make = Effect.gen(function* () { const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); if ( (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && - !publishedStateByThread.has(threadId) + !hasLivePublishedState(publishedStateByThread, threadId) ) { if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; } - if (publishedStateByThread.get(threadId) === publishIdentity) { + if ( + publishedStateByThread.get(threadId) === publishIdentity && + // A held completion settled after an earlier clear still has to alert. + !(snapshot.state === null && heldCompletions.has(threadId)) + ) { // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); heldCompletions.delete(threadId); @@ -556,10 +572,7 @@ export const make = Effect.gen(function* () { // Confirm tombstones over live state to avoid removing active cards. // Confirm completion: a run can end moments before its continuation starts, and the relay // alerts on every move into completed. - const requiresConfirmation = - (snapshot.state === null && - publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - snapshot.state?.phase === "completed"; + const requiresConfirmation = snapshot.state === null || snapshot.state?.phase === "completed"; if (requiresConfirmation) { if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; @@ -639,15 +652,9 @@ export const make = Effect.gen(function* () { }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); heldCompletions.delete(threadId); - yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { - const nextPublishedStates = new Map(publishedStates); - if (snapshot.state === null) { - nextPublishedStates.delete(threadId); - } else { - nextPublishedStates.set(threadId, publishIdentity); - } - return nextPublishedStates; - }); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => + new Map(publishedStates).set(threadId, publishIdentity), + ); }); const processThreadPublish = Effect.fnUntraced(function* (threadId: ThreadId) { diff --git a/apps/server/src/requestUrl.ts b/apps/server/src/requestUrl.ts new file mode 100644 index 0000000000000..a1a0371c5f97f --- /dev/null +++ b/apps/server/src/requestUrl.ts @@ -0,0 +1,14 @@ +import * as Option from "effect/Option"; +import { HttpServerRequest } from "effect/http"; + +/** + * `HttpServerRequest.toURL` that honors `X-Forwarded-Proto: https`. Effect 4.1 + * stopped reading it, but TLS-terminating proxies in front of the server + * (Tailscale Serve, tunnels) rely on it for OAuth, DPoP and pairing URLs. + */ +export const requestUrl = (request: HttpServerRequest.HttpServerRequest): Option.Option => + HttpServerRequest.toURL( + request.headers["x-forwarded-proto"] === "https" && request.protocol !== "https" + ? request.modify({ protocol: "https" }) + : request, + ); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 0d9e442c4546a..741cd00285887 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -16,7 +16,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -365,7 +365,7 @@ function canCommandNativeTelemetrySidecar( export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const config = yield* ServerConfig.ServerConfig; const initializedAt = yield* DateTime.now; const state = yield* Ref.make(initialState); @@ -864,7 +864,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), }); } - const requestId = yield* crypto.randomUUIDv4().pipe( + const requestId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new NativeTelemetryCommandFailed({ @@ -923,7 +923,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu }); } - const requestId = yield* crypto.randomUUIDv4().pipe( + const requestId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new NativeTelemetryCommandFailed({ @@ -978,8 +978,8 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu }); } - const requestId = yield* crypto - .randomUUIDv4() + const requestId = yield* random + .uuidV4() .pipe( Effect.mapError( (cause) => new NativeTelemetryCommandFailed({ operation: "createRequestId", cause }), diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 3d91ffa623a6c..afeb73de3df96 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -28,7 +28,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -66,6 +66,7 @@ import { acceptImmediateWebhookDedupe, releaseWebhookReplay, decodePendingTask, + encodePendingTask, decodePendingRequest, dedupeError, } from "./webhookDedupe.ts"; @@ -435,7 +436,7 @@ export const layer = Layer.effect( ScheduledTaskService, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; @@ -573,7 +574,7 @@ export const layer = Layer.effect( ), ); - const newWebhookToken = crypto.randomBytes(32).pipe( + const newWebhookToken = random.randomBytes(32).pipe( Effect.map((bytes) => Buffer.from(bytes).toString("base64url")), Effect.mapError((cause) => taskError("Could not generate webhook token.", { cause })), ); @@ -880,7 +881,6 @@ export const layer = Layer.effect( : `${active.id}:webhook:${webhook.deliveryId}`; const commandId = CommandId.make(`scheduled-task:${fireKey}`); const messageId = MessageId.make(`scheduled-task-message:${fireKey}`); - // A webhook prompt was rendered from the row when the request arrived. const prompt = webhook?.prompt ?? active.prompt; // A bound thread can move to another project (No project to a project) while @@ -1219,8 +1219,8 @@ export const layer = Layer.effect( const now = yield* localNow; const uuid = input.commandId === undefined - ? yield* crypto - .randomUUIDv4() + ? yield* random + .uuidV4() .pipe( Effect.mapError((cause) => taskError("Could not generate schedule task id.", { cause }), @@ -1950,8 +1950,8 @@ export const layer = Layer.effect( observeDelivery(request, outcome, receivedAt, now); const deliveryId = ScheduledTaskWebhookDeliveryId.make( request.relayDeliveryId === undefined - ? `delivery:${yield* crypto - .randomUUIDv4() + ? `delivery:${yield* random + .uuidV4() .pipe( Effect.mapError((cause) => taskError("Could not generate delivery id.", { cause })), )}` @@ -2180,7 +2180,7 @@ export const layer = Layer.effect( claimAcquired = false; return { _tag: "accepted", deliveryId, outcome } as const; } - const frameId = yield* crypto.randomBytes(6).pipe( + const frameId = yield* random.randomBytes(6).pipe( Effect.map((bytes) => Buffer.from(bytes).toString("hex")), Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), ); @@ -2331,10 +2331,21 @@ export const layer = Layer.effect( continue; } const prepared = yield* Effect.gen(function* () { - const task = yield* decodePendingTask(entry.task_json); + // A running row was already sent, so recovery keeps the prompt it was sent with. + const current = entry.state === "dispatching" ? yield* findTask(taskId) : null; + const snapshot = yield* decodePendingTask(entry.task_json); + // The snapshot keeps the identity runTask checks; the prompt is the one saved now. + const task = + current?.createdAt === snapshot.createdAt && current.prompt !== snapshot.prompt + ? { ...snapshot, prompt: current.prompt } + : snapshot; const request = yield* decodePendingRequest(entry.request_json); - const frameId = Buffer.from(yield* crypto.randomBytes(6)).toString("hex"); - return { task, rendered: renderWebhookPrompt(task.prompt, request, frameId) }; + const frameId = Buffer.from(yield* random.randomBytes(6)).toString("hex"); + return { + task, + taskJson: task === snapshot ? null : yield* encodePendingTask(task), + rendered: renderWebhookPrompt(task.prompt, request, frameId), + }; }).pipe(Effect.exit); if (Exit.isFailure(prepared)) { if (Cause.hasInterruptsOnly(prepared.cause)) @@ -2347,11 +2358,13 @@ export const layer = Layer.effect( yield* remove; continue; } - const { task, rendered } = prepared.value; + const { task, taskJson, rendered } = prepared.value; if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); yield* releaseFailedDelivery(entry.delivery_id); } else { + if (taskJson !== null) + yield* sql`UPDATE scheduled_task_webhook_pending SET task_json = ${taskJson} WHERE delivery_id = ${entry.delivery_id}`; yield* sql`UPDATE scheduled_task_webhook_deliveries SET outcome = 'accepted', error = NULL, rendered_prompt = ${rendered.prompt.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT)}, missing_fields_json = ${encodeMissingFieldsJson(rendered.missing)} WHERE delivery_id = ${entry.delivery_id}`; if (!(yield* runWebhookDelivery(task, entry.delivery_id, rendered.prompt))) return; } diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 6fb5de1ff8873..6303b38c05ce6 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -10,7 +10,7 @@ import { SecretRequestError, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -185,9 +185,9 @@ const withService = ( options.frameGate === undefined && options.onFrame === undefined ? NodePlatformCrypto.layer : Layer.effect( - Crypto.Crypto, + SecureRandom.SecureRandom, Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const crypto = yield* SecureRandom.SecureRandom; return { ...crypto, randomBytes: (size: number) => @@ -1031,6 +1031,106 @@ it.effect.each([1, 3])( }, ); +it.effect("a coalesced window dispatches the prompt saved after it opened", () => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const schedule = { + type: "webhook", + dedupe: { coalesceKey: "{{body.id}}", coalesceWindowSeconds: 10 }, + }; + const { task } = yield* service.upsert( + yield* webhookTaskInput({ prompt: "Use the original instructions.", schedule }), + ); + const bodyText = JSON.stringify({ id: "review" }); + yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + yield* service.upsert( + yield* webhookTaskInput({ prompt: "Use the updated instructions.", schedule }), + ); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + const launched = yield* Queue.take(launches); + assert.equal(launched.initialMessage?.text, "Use the updated instructions."); + const owner = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + const { delivery } = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + }); + assert.equal(delivery.renderedPrompt, "Use the updated instructions."); + }), + { workers }, + ); +}); + +it.effect("retrying a running window keeps the prompt it was dispatched with", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const schedule = { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }; + const save = (prompt: string) => + Effect.flatMap(webhookTaskInput({ prompt, schedule }), service.upsert); + const { task } = yield* save("Prompt when the window opened"); + yield* service.triggerWebhook(requestFor(task)); + yield* save("Prompt at dispatch"); + yield* sql`CREATE TRIGGER fail_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const launched = yield* Queue.take(launches); + assert.equal(launched.initialMessage?.text, "Prompt at dispatch"); + yield* save("Prompt saved after dispatch"); + yield* sql`DROP TRIGGER fail_completion`; + yield* drainPendingDispatch(workers); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const owner = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + const { delivery } = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + }); + assert.equal(delivery.renderedPrompt, "Prompt at dispatch"); + }), + { workers }, + ); +}); + +it.effect("a coalesced window fails its delivery when the task row cannot be read", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* sql`UPDATE scheduled_tasks SET model_selection_json = 'not json' WHERE task_id = ${task.id}`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const [delivery] = yield* sql<{ + outcome: string; + error: string | null; + }>`SELECT outcome, error FROM scheduled_task_webhook_deliveries`; + assert.equal(delivery?.outcome, "dispatch_failed"); + // Failing before dispatch means a later successful read cannot send the stale snapshot. + assert.equal(delivery?.error, "The pending webhook payload could not be prepared."); + }), + { workers }, + ); +}); + it.effect("recovers a persisted coalescing window after restarting the service", () => { const workers = new Map>(); return withService( diff --git a/apps/server/src/scheduledTasks/webhookDedupe.ts b/apps/server/src/scheduledTasks/webhookDedupe.ts index 21d40b25ac5ed..76f04a97e6dfe 100644 --- a/apps/server/src/scheduledTasks/webhookDedupe.ts +++ b/apps/server/src/scheduledTasks/webhookDedupe.ts @@ -111,7 +111,7 @@ export const acceptImmediateWebhookDedupe = Effect.fn( }); const PendingTaskJson = Schema.fromJsonString(ScheduledTask); -const encodePendingTask = Schema.encodeEffect(PendingTaskJson); +export const encodePendingTask = Schema.encodeEffect(PendingTaskJson); export const decodePendingTask = Schema.decodeUnknownEffect(PendingTaskJson); const PendingRequestJson = Schema.fromJsonString( Schema.Struct({ diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts index b17458e2d8f13..83a0adc5f11dd 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -1,10 +1,10 @@ -import { secretReferencesInSettings } from "@t3tools/contracts"; +import { secretReferencesInSettings, withSettingsSecrets } from "@t3tools/contracts"; import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; /** - * Replaces each reference in the fields `schema` declares as `SettingsSecretValue` with the secret + * Replaces each reference in the slots `schema` declares as `SettingsSecretValue` with the secret * it names, read in one batch. An unreadable secret comes back as `""`, so it behaves as unset. */ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(function* < @@ -13,8 +13,8 @@ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(funct const pending = secretReferencesInSettings(schema, settings); if (pending.length === 0) return settings; const values = yield* resolver.read(pending.map(({ reference }) => reference)); - const resolved = Object.fromEntries( - pending.map(({ field }, index) => [field, values[index] ?? ""] as const), + return withSettingsSecrets( + settings, + pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })), ); - return { ...settings, ...resolved }; }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 51afd7a5a31c2..cf9710011e1a1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,6 +20,7 @@ import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQ import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -57,7 +58,7 @@ import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewG import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as DelegatedTaskAnalytics from "./telemetry/DelegatedTaskAnalytics.ts"; -import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; +import * as ProviderUsageAnalytics from "./telemetry/ProviderUsageAnalytics.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts"; @@ -69,6 +70,7 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; import * as DocumentText from "./provider/DocumentText.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as NativeChildWorkQuery from "./orchestration-v2/NativeChildWorkQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; @@ -388,7 +390,9 @@ const layerHttpServer = Layer.unwrap( }), ).pipe(Layer.provide(ManagedPolicy.layer)); -const layerPlatformServices = DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)); +const layerPlatformServices = DocumentText.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.layerConfig)); @@ -575,7 +579,7 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( Layer.provide(layerScheduledTaskWebhookOrigin), - Layer.provide(ProviderEventIngestor.layerAnalytics), + Layer.provide(ProviderUsageAnalytics.layer), Layer.provide(layerCheckpointStore), Layer.provide(layerGitWorkflow), Layer.provide( @@ -594,10 +598,10 @@ const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( Layer.provide(ProjectKnowledge.layerDeliveryScrubber), ); -const layerOrchestrationApplication = CheckpointDiffQuery.layer.pipe( - Layer.provideMerge(layerCheckpointStore), - Layer.provideMerge(layerOrchestrationV2Runtime), -); +const layerOrchestrationApplication = Layer.merge( + CheckpointDiffQuery.layer, + NativeChildWorkQuery.layer, +).pipe(Layer.provideMerge(layerCheckpointStore), Layer.provideMerge(layerOrchestrationV2Runtime)); const layerThreadSettlementWorker = Layer.effectDiscard( ThreadSettlementService.make.pipe(Effect.flatMap((service) => service.start())), diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index f0204cdd8f660..92c35f19a843a 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -6,7 +6,7 @@ import * as NodeSqlite from "node:sqlite"; import { SERVER_EXIT_CODE_STATE_DIR_OWNED } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { CLI_RELEASES_PAGE_URL } from "@t3tools/shared/cliRelease"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -185,9 +185,9 @@ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(functi statePath: string, ) { const stateDir = NodePath.dirname(statePath); - const crypto = yield* Crypto.Crypto; - const ownerId = yield* crypto - .randomUUIDv4() + const random = yield* SecureRandom.SecureRandom; + const ownerId = yield* random + .uuidV4() .pipe(Effect.mapError((cause) => new ServerOwnershipError({ stateDir, cause }))); const lockPath = NodePath.join(stateDir, "server-owner.sqlite"); const lock = yield* Effect.acquireRelease( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 54fef56e311d8..e37f3078548ce 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -14,7 +14,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -261,8 +261,8 @@ const resolveWelcomeBase = Effect.gen(function* () { }); const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const randomUUID = crypto.randomUUIDv4(); + const random = yield* SecureRandom.SecureRandom; + const randomUUID = random.uuidV4(); const serverConfig = yield* ServerConfig.ServerConfig; const projects = yield* ProjectService.ProjectService; const threads = yield* ThreadManagement.ThreadManagementService; @@ -451,7 +451,7 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const commandGate = yield* makeCommandGate; @@ -557,7 +557,9 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd ? runStartupPhase( "welcome.autobootstrap", - resolveAutoBootstrapWelcomeTargets.pipe(Effect.provideService(Crypto.Crypto, crypto)), + resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(SecureRandom.SecureRandom, random), + ), ) : Effect.succeed({}) ).pipe(Effect.map((targets): AutoBootstrapWelcomeTargets => targets)), diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index b75ab72594add..5a14cf139577e 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -3,6 +3,7 @@ import * as NodeFS from "node:fs"; import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; @@ -145,7 +146,7 @@ describe("server ownership", () => { }), ), Layer.succeed(HostProcess.Environment, {}), - ).pipe(Layer.provideMerge(NodeServices.layer)); + ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const makeState = ServerRuntimeState.makePersistedServerRuntimeState({ config: { host: undefined, devUrl: undefined }, port: 3773, @@ -373,7 +374,7 @@ describe("server ownership", () => { assert.include(result.failure.message, "t3 service restart"); assert.include(result.failure.message, "No server was stopped"); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -392,7 +393,7 @@ describe("server ownership", () => { if (result._tag !== "Failure") return; assert.equal(result.failure._tag, "OlderBootServiceError"); assert.equal(Runtime.getErrorExitCode(result.failure), 78); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("refuses while the loaded service has not restarted into its lock-aware update", () => @@ -406,7 +407,7 @@ describe("server ownership", () => { }, }); assert.equal(result._tag === "Failure" && result.failure._tag, "OlderBootServiceError"); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("allows lock-aware stable services that are loaded or stopped and enabled", () => @@ -429,7 +430,7 @@ describe("server ownership", () => { const { result } = yield* startBesideBootService({ ...fixture, activeVersion: "0.1.0" }); assert.equal(result._tag, "Success"); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("allows services that cannot compete for this home", () => @@ -466,7 +467,7 @@ describe("server ownership", () => { const { result } = yield* startBesideBootService(fixture); assert.equal(result._tag, "Success"); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("allows startup when the service configuration or manager is unavailable", () => @@ -484,7 +485,7 @@ describe("server ownership", () => { const { result } = yield* startBesideBootService({ platform: "darwin", ...fixture }); assert.equal(result._tag, "Success"); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 95ab405f318d4..2b8f437003a59 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, @@ -101,7 +102,7 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi `; }); -it.layer(NodeServices.layer)("server settings", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings", (it) => { it.effect("refuses a pool whose members are unknown, repeated, or on another driver", () => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; @@ -1684,20 +1685,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { provider: SecretProviderId.make("personal"), reference: "op://Private/Bitbucket/token", }; + const gitHubReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/GitHub/token", + }; yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, + sourceControlHosts: { + bitbucket: { accessToken: "bb-access" }, + github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken", null))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "github.com"))); const referenced = yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: reference } }, + sourceControlHosts: { + bitbucket: { accessToken: reference }, + github: { tokens: { "github.com": gitHubReference, "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.deepEqual(referenced.sourceControlHosts.bitbucket?.accessToken, reference); assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); - assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts - .bitbucket?.accessToken, - reference, - ); + assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); + const redacted = + ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts; + assert.deepEqual(redacted.bitbucket?.accessToken, reference); + const tokens = redacted.github?.tokens as Record; + assert.deepEqual(tokens["github.com"], gitHubReference); + assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe"); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); @@ -2135,643 +2150,679 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }); -it.layer(NodeServices.layer)("server settings under a managed policy", (it) => { - const policy: ManagedPolicyDocument = { - schemaVersion: 1, - organization: "Example Corp", - settings: { - defaultRuntimeMode: { value: "approval-required", locked: false }, - enableAgentDeviceAccess: { value: false, locked: true }, - }, - }; +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "server settings under a managed policy", + (it) => { + const policy: ManagedPolicyDocument = { + schemaVersion: 1, + organization: "Example Corp", + settings: { + defaultRuntimeMode: { value: "approval-required", locked: false }, + enableAgentDeviceAccess: { value: false, locked: true }, + }, + }; - it.effect("uses policy defaults until the user picks something else", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; + it.effect("uses policy defaults until the user picks something else", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; - assert.equal((yield* service.getSettings).defaultRuntimeMode, "approval-required"); + assert.equal((yield* service.getSettings).defaultRuntimeMode, "approval-required"); - // The built-in default differs from the policy's, so choosing it must persist. - yield* service.updateSettings({ defaultRuntimeMode: "full-access" }); - assert.equal((yield* service.getSettings).defaultRuntimeMode, "full-access"); - assert.include(yield* fs.readFileString(config.settingsPath), '"full-access"'); + // The built-in default differs from the policy's, so choosing it must persist. + yield* service.updateSettings({ defaultRuntimeMode: "full-access" }); + assert.equal((yield* service.getSettings).defaultRuntimeMode, "full-access"); + assert.include(yield* fs.readFileString(config.settingsPath), '"full-access"'); - // Choosing the policy's value leaves nothing in the file to outlive the policy. - yield* service.updateSettings({ defaultRuntimeMode: "approval-required" }); - assert.notInclude(yield* fs.readFileString(config.settingsPath), "defaultRuntimeMode"); - }).pipe(Effect.provide(layerServerSettings(policy))), - ); + // Choosing the policy's value leaves nothing in the file to outlive the policy. + yield* service.updateSettings({ defaultRuntimeMode: "approval-required" }); + assert.notInclude(yield* fs.readFileString(config.settingsPath), "defaultRuntimeMode"); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); - it.effect("keeps a stored value that equals a policy default through unrelated writes", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ defaultRuntimeMode: "approval-required" }), - ); + it.effect("keeps a stored value that equals a policy default through unrelated writes", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ defaultRuntimeMode: "approval-required" }), + ); - yield* service.updateSettings({ defaultAutoPull: true }); - assert.include(yield* fs.readFileString(config.settingsPath), '"approval-required"'); - }).pipe(Effect.provide(layerServerSettings(policy))), - ); + yield* service.updateSettings({ defaultAutoPull: true }); + assert.include(yield* fs.readFileString(config.settingsPath), '"approval-required"'); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); - it.effect("keeps stored values under whole-object policy defaults, and only the user's", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const selection = { - ...DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, - model: "managed", - }; - const chosenModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-mini" }; - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ - storageCleanup: { logsAfterDays: 30, browserArtifactsAfterDays: 7 }, - textGenerationModelSelection: selection, - }), - ); + it.effect("keeps stored values under whole-object policy defaults, and only the user's", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const selection = { + ...DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + model: "managed", + }; + const chosenModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-mini" }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + storageCleanup: { logsAfterDays: 30, browserArtifactsAfterDays: 7 }, + textGenerationModelSelection: selection, + }), + ); - // Picking another model on the policy's instance differs from the policy in one field only. - yield* service.updateSettings({ defaultAutoPull: true, defaultModelSelection: chosenModel }); - const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); - assert.deepEqual(stored.storageCleanup, { logsAfterDays: 30, browserArtifactsAfterDays: 7 }); - // Model selections are saved whole, never as the fields that differ from the policy's. - assert.deepEqual(stored.textGenerationModelSelection, selection); - assert.deepEqual(stored.defaultModelSelection, chosenModel); - // The switch the server derives from the policy's idle compaction stays out of the file. - assert.notProperty(stored, "compactIdleThreads"); - }).pipe( - Effect.provide( - layerServerSettings({ - schemaVersion: 1, - organization: "Example Corp", - settings: { - storageCleanup: { - value: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, logsAfterDays: 30 }, - locked: false, - }, - "textGenerationModelSelection.model": { value: "managed", locked: false }, - defaultModelSelection: { - value: { instanceId: "codex", model: "gpt-managed" }, - locked: false, + // Picking another model on the policy's instance differs from the policy in one field only. + yield* service.updateSettings({ + defaultAutoPull: true, + defaultModelSelection: chosenModel, + }); + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(stored.storageCleanup, { + logsAfterDays: 30, + browserArtifactsAfterDays: 7, + }); + // Model selections are saved whole, never as the fields that differ from the policy's. + assert.deepEqual(stored.textGenerationModelSelection, selection); + assert.deepEqual(stored.defaultModelSelection, chosenModel); + // The switch the server derives from the policy's idle compaction stays out of the file. + assert.notProperty(stored, "compactIdleThreads"); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + settings: { + storageCleanup: { + value: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, logsAfterDays: 30 }, + locked: false, + }, + "textGenerationModelSelection.model": { value: "managed", locked: false }, + defaultModelSelection: { + value: { instanceId: "codex", model: "gpt-managed" }, + locked: false, + }, + idleCompaction: { value: "beforeCacheExpires", locked: false }, }, - idleCompaction: { value: "beforeCacheExpires", locked: false }, - }, - }), + }), + ), ), - ), - ); + ); - it.effect( - "keeps an idle compaction choice saved before idleCompaction over a policy default", - () => - // The file holds only the legacy switch: off differs from the policy's default, on matches it. - Effect.forEach([false, true], (compactIdleThreads) => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const chosen = compactIdleThreads ? "beforeCacheExpires" : "onReturn"; - yield* fs.writeFileString(config.settingsPath, JSON.stringify({ compactIdleThreads })); - - assert.equal((yield* service.getSettings).idleCompaction, chosen); - yield* service.updateSettings({ defaultAutoPull: true }); - // What applies once the policy is gone is still the user's choice. - const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); - assert.equal(globalIdleCompaction(stored), chosen); - }).pipe( - Effect.provide( - layerServerSettings({ - schemaVersion: 1, - organization: "Example Corp", - settings: { idleCompaction: { value: "beforeCacheExpires", locked: false } }, - }), + it.effect( + "keeps an idle compaction choice saved before idleCompaction over a policy default", + () => + // The file holds only the legacy switch: off differs from the policy's default, on matches it. + Effect.forEach([false, true], (compactIdleThreads) => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const chosen = compactIdleThreads ? "beforeCacheExpires" : "onReturn"; + yield* fs.writeFileString(config.settingsPath, JSON.stringify({ compactIdleThreads })); + + assert.equal((yield* service.getSettings).idleCompaction, chosen); + yield* service.updateSettings({ defaultAutoPull: true }); + // What applies once the policy is gone is still the user's choice. + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.equal(globalIdleCompaction(stored), chosen); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + settings: { idleCompaction: { value: "beforeCacheExpires", locked: false } }, + }), + ), ), ), - ), - ); + ); - it.effect("lays locks over the user's own and per-project values without persisting them", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const projectId = ProjectId.make("project-locked"); - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ - enableAgentDeviceAccess: true, - projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } }, - }), - ); + it.effect("lays locks over the user's own and per-project values without persisting them", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const projectId = ProjectId.make("project-locked"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + enableAgentDeviceAccess: true, + projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } }, + }), + ); - const settings = yield* service.getSettings; - assert.isFalse(settings.enableAgentDeviceAccess); - assert.isFalse(settings.projectSettingsOverrides[projectId]?.enableAgentDeviceAccess); - assert.deepEqual(service.managedPolicy, { - organization: "Example Corp", - lockedSettings: ["enableAgentDeviceAccess"], - }); + const settings = yield* service.getSettings; + assert.isFalse(settings.enableAgentDeviceAccess); + assert.isFalse(settings.projectSettingsOverrides[projectId]?.enableAgentDeviceAccess); + assert.deepEqual(service.managedPolicy, { + organization: "Example Corp", + lockedSettings: ["enableAgentDeviceAccess"], + }); - const rejected = yield* service - .updateSettings({ enableAgentDeviceAccess: true, defaultAutoPull: true }) - .pipe(Effect.flip); - assert.equal(rejected.operation, "write-managed-setting"); - assert.equal(rejected.managedSetting, "enableAgentDeviceAccess"); - assert.equal( - rejected.message, - "enableAgentDeviceAccess is managed by Example Corp and cannot be changed.", - ); - assert.isFalse((yield* service.getSettings).defaultAutoPull); + const rejected = yield* service + .updateSettings({ enableAgentDeviceAccess: true, defaultAutoPull: true }) + .pipe(Effect.flip); + assert.equal(rejected.operation, "write-managed-setting"); + assert.equal(rejected.managedSetting, "enableAgentDeviceAccess"); + assert.equal( + rejected.message, + "enableAgentDeviceAccess is managed by Example Corp and cannot be changed.", + ); + assert.isFalse((yield* service.getSettings).defaultAutoPull); - const projectRejected = yield* service - .updateSettings({ - projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } }, - }) - .pipe(Effect.flip); - assert.equal(projectRejected.managedSetting, "enableAgentDeviceAccess"); + const projectRejected = yield* service + .updateSettings({ + projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } }, + }) + .pipe(Effect.flip); + assert.equal(projectRejected.managedSetting, "enableAgentDeviceAccess"); - // Unrelated writes, and writes that keep the locked value, go through. - yield* service.updateSettings({ enableAgentDeviceAccess: false, defaultAutoPull: true }); - assert.isTrue((yield* service.getSettings).defaultAutoPull); + // Unrelated writes, and writes that keep the locked value, go through. + yield* service.updateSettings({ enableAgentDeviceAccess: false, defaultAutoPull: true }); + assert.isTrue((yield* service.getSettings).defaultAutoPull); - // The user's own value stays on disk, ready for when the policy is removed. - const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); - assert.isTrue(stored.enableAgentDeviceAccess); - }).pipe(Effect.provide(layerServerSettings(policy))), - ); - it.effect("accepts a project override that brings the locked value inside a new setting", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const projectId = ProjectId.make("project-text-generation"); - const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-locked" }; + // The user's own value stays on disk, ready for when the policy is removed. + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.isTrue(stored.enableAgentDeviceAccess); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + it.effect("accepts a project override that brings the locked value inside a new setting", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("project-text-generation"); + const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-locked" }; - yield* service.updateSettings({ - projectSettingsOverrides: { [projectId]: { textGenerationModelSelection: selection } }, - }); + yield* service.updateSettings({ + projectSettingsOverrides: { [projectId]: { textGenerationModelSelection: selection } }, + }); - const project = (yield* service.getSettings).projectSettingsOverrides[projectId]; - assert.equal(project?.textGenerationModelSelection?.model, "gpt-locked"); - }).pipe( - Effect.provide( - layerServerSettings({ - schemaVersion: 1, - organization: "Example Corp", - settings: { "textGenerationModelSelection.model": { value: "gpt-locked", locked: true } }, - }), + const project = (yield* service.getSettings).projectSettingsOverrides[projectId]; + assert.equal(project?.textGenerationModelSelection?.model, "gpt-locked"); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + settings: { + "textGenerationModelSelection.model": { value: "gpt-locked", locked: true }, + }, + }), + ), ), - ), - ); + ); - it.effect("compares a project's partial override with a locked entry by the keys it holds", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const codexId = ProviderInstanceId.make("codex"); - const projectWith = (enabled: boolean) => ({ - projectSettingsOverrides: { - [ProjectId.make("project-codex")]: { providerInstances: { [codexId]: { enabled } } }, - }, - }); + it.effect( + "compares a project's partial override with a locked entry by the keys it holds", + () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const codexId = ProviderInstanceId.make("codex"); + const projectWith = (enabled: boolean) => ({ + projectSettingsOverrides: { + [ProjectId.make("project-codex")]: { providerInstances: { [codexId]: { enabled } } }, + }, + }); - yield* service.updateSettings(projectWith(true)); - const refused = yield* service.updateSettings(projectWith(false)).pipe(Effect.flip); - assert.equal(refused.managedSetting, "providerInstances.codex"); - }).pipe( - Effect.provide( - layerServerSettings({ - schemaVersion: 1, - organization: "Example Corp", - settings: { - "providerInstances.codex": { value: { driver: "codex", enabled: true }, locked: true }, - }, - }), - ), - ), - ); + yield* service.updateSettings(projectWith(true)); + const refused = yield* service.updateSettings(projectWith(false)).pipe(Effect.flip); + assert.equal(refused.managedSetting, "providerInstances.codex"); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + settings: { + "providerInstances.codex": { + value: { driver: "codex", enabled: true }, + locked: true, + }, + }, + }), + ), + ), + ); - it.effect("lowers default runtime modes above the ceiling without rewriting them", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const raised = ProjectId.make("project-raised"); - const supervised = ProjectId.make("project-supervised"); - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ - defaultRuntimeMode: "auto", - projectSettingsOverrides: { - [raised]: { defaultRuntimeMode: "full-access" }, - [supervised]: { defaultRuntimeMode: "approval-required" }, - }, - }), - ); + it.effect("lowers default runtime modes above the ceiling without rewriting them", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const raised = ProjectId.make("project-raised"); + const supervised = ProjectId.make("project-supervised"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + defaultRuntimeMode: "auto", + projectSettingsOverrides: { + [raised]: { defaultRuntimeMode: "full-access" }, + [supervised]: { defaultRuntimeMode: "approval-required" }, + }, + }), + ); - const settings = yield* service.getSettings; - assert.equal(settings.defaultRuntimeMode, "auto-accept-edits"); - assert.equal( - settings.projectSettingsOverrides[raised]?.defaultRuntimeMode, - "auto-accept-edits", - ); - assert.equal( - settings.projectSettingsOverrides[supervised]?.defaultRuntimeMode, - "approval-required", - ); + const settings = yield* service.getSettings; + assert.equal(settings.defaultRuntimeMode, "auto-accept-edits"); + assert.equal( + settings.projectSettingsOverrides[raised]?.defaultRuntimeMode, + "auto-accept-edits", + ); + assert.equal( + settings.projectSettingsOverrides[supervised]?.defaultRuntimeMode, + "approval-required", + ); - yield* service.updateSettings({ defaultAutoPull: true }); - const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); - assert.equal(stored.defaultRuntimeMode, "auto"); - assert.equal(stored.projectSettingsOverrides[raised].defaultRuntimeMode, "full-access"); - }).pipe( - Effect.provide( - layerServerSettings({ - schemaVersion: 1, - organization: "Example Corp", - limits: { maxRuntimeMode: "auto-accept-edits" }, - }), + yield* service.updateSettings({ defaultAutoPull: true }); + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.equal(stored.defaultRuntimeMode, "auto"); + assert.equal(stored.projectSettingsOverrides[raised].defaultRuntimeMode, "full-access"); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + limits: { maxRuntimeMode: "auto-accept-edits" }, + }), + ), ), - ), - ); -}); - -it.layer(NodeServices.layer)("server settings under a provider policy", (it) => { - const claude = ProviderDriverKind.make("claudeAgent"); - const codex = ProviderDriverKind.make("codex"); - const cursor = ProviderDriverKind.make("cursor"); - const bedrock = { name: "CLAUDE_CODE_USE_BEDROCK", value: "1", sensitive: false }; - const policy: ManagedPolicyDocument = { - schemaVersion: 1, - organization: "Example Corp", - limits: { - providers: { - allowedDrivers: [claude, codex], - instances: { - [claude]: { - locked: true, - environment: [{ name: bedrock.name, value: bedrock.value }], - binaryPath: "/opt/claude/bin/claude", + ); + }, +); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "server settings under a provider policy", + (it) => { + const claude = ProviderDriverKind.make("claudeAgent"); + const codex = ProviderDriverKind.make("codex"); + const cursor = ProviderDriverKind.make("cursor"); + const bedrock = { name: "CLAUDE_CODE_USE_BEDROCK", value: "1", sensitive: false }; + const policy: ManagedPolicyDocument = { + schemaVersion: 1, + organization: "Example Corp", + limits: { + providers: { + allowedDrivers: [claude, codex], + instances: { + [claude]: { + locked: true, + environment: [{ name: bedrock.name, value: bedrock.value }], + binaryPath: "/opt/claude/bin/claude", + }, + [codex]: { locked: false, launchArgs: "-c model_provider=gateway" }, }, - [codex]: { locked: false, launchArgs: "-c model_provider=gateway" }, }, }, - }, - }; + }; - it.effect("refuses enabling a driver the policy leaves out, and keeps existing ones", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const cursorId = ProviderInstanceId.make("cursor_work"); - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ - providerInstances: { - [cursorId]: { driver: cursor, enabled: true }, - // A default instance id is no proof of its driver. - codex: { driver: cursor, enabled: true }, - }, - textGenerationModelSelection: { instanceId: cursorId, model: "auto" }, - }), - ); + it.effect("refuses enabling a driver the policy leaves out, and keeps existing ones", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const cursorId = ProviderInstanceId.make("cursor_work"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { + [cursorId]: { driver: cursor, enabled: true }, + // A default instance id is no proof of its driver. + codex: { driver: cursor, enabled: true }, + }, + textGenerationModelSelection: { instanceId: cursorId, model: "auto" }, + }), + ); - const settings = yield* service.getSettings; - // Never deleted: the instance stays for its threads, and the registry shows it unavailable. - assert.equal(settings.providerInstances[cursorId]?.driver, cursor); - // Built-in text generation does not fall back onto a disallowed driver either. - assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + const settings = yield* service.getSettings; + // Never deleted: the instance stays for its threads, and the registry shows it unavailable. + assert.equal(settings.providerInstances[cursorId]?.driver, cursor); + // Built-in text generation does not fall back onto a disallowed driver either. + assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); - const rejected = yield* service - .updateProviderInstance({ - operation: "create", - instanceId: ProviderInstanceId.make("cursor_new"), - instance: { driver: cursor, enabled: true }, - }) - .pipe(Effect.flip); - assert.equal(rejected.operation, "use-disallowed-provider"); - assert.equal(rejected.message, "Example Corp's policy does not allow the Cursor provider."); - // Switching it on for one project is enabling it too. - const perProject = yield* service - .updateSettings({ - projectSettingsOverrides: { - [ProjectId.make("project-a")]: { providerInstances: { [cursorId]: { enabled: true } } }, - }, - }) - .pipe(Effect.flip); - assert.equal(perProject.operation, "use-disallowed-provider"); + const rejected = yield* service + .updateProviderInstance({ + operation: "create", + instanceId: ProviderInstanceId.make("cursor_new"), + instance: { driver: cursor, enabled: true }, + }) + .pipe(Effect.flip); + assert.equal(rejected.operation, "use-disallowed-provider"); + assert.equal(rejected.message, "Example Corp's policy does not allow the Cursor provider."); + // Switching it on for one project is enabling it too. + const perProject = yield* service + .updateSettings({ + projectSettingsOverrides: { + [ProjectId.make("project-a")]: { + providerInstances: { [cursorId]: { enabled: true } }, + }, + }, + }) + .pipe(Effect.flip); + assert.equal(perProject.operation, "use-disallowed-provider"); - // The client echoes a default instance that was running before it was configured. - yield* service.updateProviderInstance({ - operation: "upsert", - instanceId: ProviderInstanceId.make("cursor"), - instance: { driver: cursor, enabled: true }, - }); + // The client echoes a default instance that was running before it was configured. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: ProviderInstanceId.make("cursor"), + instance: { driver: cursor, enabled: true }, + }); - // Turning it off, or removing it, is still the user's call. - yield* service.updateProviderInstance({ - operation: "upsert", - instanceId: cursorId, - instance: { driver: cursor, enabled: false }, - }); - yield* service.updateProviderInstance({ operation: "remove", instanceId: cursorId }); - assert.isUndefined((yield* service.getSettings).providerInstances[cursorId]); - }).pipe(Effect.provide(layerServerSettings(policy))), - ); + // Turning it off, or removing it, is still the user's call. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: cursorId, + instance: { driver: cursor, enabled: false }, + }); + yield* service.updateProviderInstance({ operation: "remove", instanceId: cursorId }); + assert.isUndefined((yield* service.getSettings).providerInstances[cursorId]); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); - it.effect("keeps a locked Codex pin from being bypassed by managed setup", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const codexId = ProviderInstanceId.make("codex_chatgpt"); - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ - providerInstances: { [codexId]: { driver: codex, config: { setupMode: "managed" } } }, - }), - ); + it.effect("keeps a locked Codex pin from being bypassed by managed setup", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const codexId = ProviderInstanceId.make("codex_chatgpt"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { [codexId]: { driver: codex, config: { setupMode: "managed" } } }, + }), + ); - const effective = (yield* service.getSettings).providerInstances[codexId]; - assert.deepEqual(effective?.config, { binaryPath: "/opt/gateway/codex" }); - // A stored value stays for when the policy is gone; a new one is refused. - const refused = yield* service - .updateProviderInstance({ + const effective = (yield* service.getSettings).providerInstances[codexId]; + assert.deepEqual(effective?.config, { binaryPath: "/opt/gateway/codex" }); + // A stored value stays for when the policy is gone; a new one is refused. + const refused = yield* service + .updateProviderInstance({ + operation: "upsert", + instanceId: ProviderInstanceId.make("codex_new"), + instance: { driver: codex, config: { setupMode: "managed" } }, + }) + .pipe(Effect.flip); + assert.equal(refused.managedSetting, "providerInstances.codex_new.config.setupMode"); + // Adding a Codex instance sends the default setup, which leaves the pin in charge. + yield* service.updateProviderInstance({ operation: "upsert", - instanceId: ProviderInstanceId.make("codex_new"), - instance: { driver: codex, config: { setupMode: "managed" } }, - }) - .pipe(Effect.flip); - assert.equal(refused.managedSetting, "providerInstances.codex_new.config.setupMode"); - // Adding a Codex instance sends the default setup, which leaves the pin in charge. - yield* service.updateProviderInstance({ - operation: "upsert", - instanceId: ProviderInstanceId.make("codex_cli"), - instance: { driver: codex, config: { setupMode: "existing" } }, - }); - }).pipe( - Effect.provide( - layerServerSettings({ - schemaVersion: 1, - organization: "Example Corp", - limits: { - providers: { - instances: { [codex]: { locked: true, binaryPath: "/opt/gateway/codex" } }, + instanceId: ProviderInstanceId.make("codex_cli"), + instance: { driver: codex, config: { setupMode: "existing" } }, + }); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + limits: { + providers: { + instances: { [codex]: { locked: true, binaryPath: "/opt/gateway/codex" } }, + }, }, - }, - }), + }), + ), ), - ), - ); + ); - it.effect("pins instance fields at read time and refuses edits to locked ones", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const claudeId = ProviderInstanceId.make("claudeAgent"); - const codexId = ProviderInstanceId.make("codex"); - const userVariable = { name: "MY_FLAG", value: "on", sensitive: false }; - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ - providerInstances: { - [claudeId]: { - driver: claude, - environment: [{ ...bedrock, value: "0" }, userVariable, { ...bedrock, value: "2" }], - config: { binaryPath: "/usr/local/bin/claude" }, + it.effect("pins instance fields at read time and refuses edits to locked ones", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const claudeId = ProviderInstanceId.make("claudeAgent"); + const codexId = ProviderInstanceId.make("codex"); + const userVariable = { name: "MY_FLAG", value: "on", sensitive: false }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { + [claudeId]: { + driver: claude, + environment: [{ ...bedrock, value: "0" }, userVariable, { ...bedrock, value: "2" }], + config: { binaryPath: "/usr/local/bin/claude" }, + }, }, - }, - }), - ); + }), + ); - const effective = yield* service.getSettings; - const claudeInstance = effective.providerInstances[claudeId]; - assert.deepEqual(claudeInstance?.environment, [bedrock, userVariable]); - assert.deepEqual(claudeInstance?.config, { binaryPath: "/opt/claude/bin/claude" }); - // The implicit default Codex instance gets the unlocked default too. - assert.deepEqual(effective.providerInstances[codexId]?.config, { - launchArgs: "-c model_provider=gateway", - }); + const effective = yield* service.getSettings; + const claudeInstance = effective.providerInstances[claudeId]; + assert.deepEqual(claudeInstance?.environment, [bedrock, userVariable]); + assert.deepEqual(claudeInstance?.config, { binaryPath: "/opt/claude/bin/claude" }); + // The implicit default Codex instance gets the unlocked default too. + assert.deepEqual(effective.providerInstances[codexId]?.config, { + launchArgs: "-c model_provider=gateway", + }); - const lockedBinary = yield* service - .updateProviderInstance({ + const lockedBinary = yield* service + .updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { ...claudeInstance!, config: { binaryPath: "/tmp/claude" } }, + }) + .pipe(Effect.flip); + assert.equal(lockedBinary.operation, "write-managed-setting"); + assert.equal( + lockedBinary.managedSetting, + "providerInstances.claudeAgent.config.binaryPath", + ); + const lockedVariable = yield* service + .updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { ...claudeInstance!, environment: [userVariable] }, + }) + .pipe(Effect.flip); + assert.equal( + lockedVariable.managedSetting, + "providerInstances.claudeAgent.environment.CLAUDE_CODE_USE_BEDROCK", + ); + + // Saving what the client was shown, plus an unrelated edit, goes through without baking + // the policy into the file: the user's own values come back once the policy is removed. + yield* service.updateProviderInstance({ operation: "upsert", instanceId: claudeId, - instance: { ...claudeInstance!, config: { binaryPath: "/tmp/claude" } }, - }) - .pipe(Effect.flip); - assert.equal(lockedBinary.operation, "write-managed-setting"); - assert.equal(lockedBinary.managedSetting, "providerInstances.claudeAgent.config.binaryPath"); - const lockedVariable = yield* service - .updateProviderInstance({ + instance: { ...claudeInstance!, displayName: "Claude (work)" }, + }); + yield* service.updateProviderInstance({ operation: "upsert", - instanceId: claudeId, - instance: { ...claudeInstance!, environment: [userVariable] }, - }) - .pipe(Effect.flip); - assert.equal( - lockedVariable.managedSetting, - "providerInstances.claudeAgent.environment.CLAUDE_CODE_USE_BEDROCK", - ); - - // Saving what the client was shown, plus an unrelated edit, goes through without baking - // the policy into the file: the user's own values come back once the policy is removed. - yield* service.updateProviderInstance({ - operation: "upsert", - instanceId: claudeId, - instance: { ...claudeInstance!, displayName: "Claude (work)" }, - }); - yield* service.updateProviderInstance({ - operation: "upsert", - instanceId: codexId, - instance: effective.providerInstances[codexId]!, - }); - const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); - assert.deepEqual(stored.providerInstances[claudeId], { - driver: claude, - displayName: "Claude (work)", - environment: [{ ...bedrock, value: "0" }, { ...bedrock, value: "2" }, userVariable], - config: { binaryPath: "/usr/local/bin/claude" }, - }); - assert.isUndefined(stored.providerInstances[codexId]); - - // Unlocked fields are the user's to override. - yield* service.updateProviderInstance({ - operation: "upsert", - instanceId: codexId, - instance: { driver: codex, config: { launchArgs: "--mine" } }, - }); - assert.deepEqual((yield* service.getSettings).providerInstances[codexId]?.config, { - launchArgs: "--mine", - }); - }).pipe(Effect.provide(layerServerSettings(policy))), - ); -}); + instanceId: codexId, + instance: effective.providerInstances[codexId]!, + }); + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(stored.providerInstances[claudeId], { + driver: claude, + displayName: "Claude (work)", + environment: [{ ...bedrock, value: "0" }, { ...bedrock, value: "2" }, userVariable], + config: { binaryPath: "/usr/local/bin/claude" }, + }); + assert.isUndefined(stored.providerInstances[codexId]); -it.layer(NodeServices.layer)("server settings under a runtime mode ceiling", (it) => { - const claude = ProviderDriverKind.make("claudeAgent"); - const acpRegistry = ProviderDriverKind.make("acpRegistry"); - const policy: ManagedPolicyDocument = { - schemaVersion: 1, - organization: "Example Corp", - limits: { - maxRuntimeMode: "auto-accept-edits", - providers: { - instances: { - [claude]: { - locked: false, - environment: [{ name: "CLAUDE_CODE_USE_BEDROCK", value: "1" }], - binaryPath: "/opt/claude/bin/claude", + // Unlocked fields are the user's to override. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: codexId, + instance: { driver: codex, config: { launchArgs: "--mine" } }, + }); + assert.deepEqual((yield* service.getSettings).providerInstances[codexId]?.config, { + launchArgs: "--mine", + }); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + }, +); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "server settings under a runtime mode ceiling", + (it) => { + const claude = ProviderDriverKind.make("claudeAgent"); + const acpRegistry = ProviderDriverKind.make("acpRegistry"); + const policy: ManagedPolicyDocument = { + schemaVersion: 1, + organization: "Example Corp", + limits: { + maxRuntimeMode: "auto-accept-edits", + providers: { + instances: { + [claude]: { + locked: false, + environment: [{ name: "CLAUDE_CODE_USE_BEDROCK", value: "1" }], + binaryPath: "/opt/claude/bin/claude", + }, }, }, }, - }, - }; + }; - it.effect("keeps the secret of a variable it hides when the instance is saved", () => { - const shared = ServerSecretStore.layer.pipe( - Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), - Layer.provideMerge( - Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-hidden-secret-" })), - ), - ); - const withPolicy = ( - document: ManagedPolicyDocument | undefined, - use: (service: ServerSettingsModule.ServerSettingsService["Service"]) => Effect.Effect, - ) => - ServerSettingsModule.ServerSettingsService.pipe( - Effect.flatMap(use), - Effect.provide( - ServerSettingsModule.layer.pipe(Layer.provide(ManagedPolicy.layerTest(document))), + it.effect("keeps the secret of a variable it hides when the instance is saved", () => { + const shared = ServerSecretStore.layer.pipe( + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-hidden-secret-" })), ), ); - const claudeId = ProviderInstanceId.make("claudeAgent"); - const token = { name: "ANTHROPIC_API_KEY", value: "sk-kept", sensitive: true }; - return Effect.gen(function* () { - yield* withPolicy(undefined, (service) => - service.updateProviderInstance({ - operation: "upsert", - instanceId: claudeId, - instance: { driver: claude, environment: [token] }, - }), - ); - // The ceiling shows only pinned variables, so the client saves without the token. - yield* withPolicy(policy, (service) => - service.getSettings.pipe( - Effect.flatMap((settings) => - service.updateProviderInstance({ - operation: "upsert", - instanceId: claudeId, - instance: { ...settings.providerInstances[claudeId]!, displayName: "Claude (work)" }, - }), + const withPolicy = ( + document: ManagedPolicyDocument | undefined, + use: ( + service: ServerSettingsModule.ServerSettingsService["Service"], + ) => Effect.Effect, + ) => + ServerSettingsModule.ServerSettingsService.pipe( + Effect.flatMap(use), + Effect.provide( + ServerSettingsModule.layer.pipe(Layer.provide(ManagedPolicy.layerTest(document))), ), - ), - ); - const restored = yield* withPolicy(undefined, (service) => service.getSettings); - assert.equal(restored.providerInstances[claudeId]?.environment?.[0]?.value, token.value); - }).pipe(Effect.provide(shared)); - }); - - it.effect("ignores stored launch configuration and refuses new values", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; + ); const claudeId = ProviderInstanceId.make("claudeAgent"); - const registryId = ProviderInstanceId.make("acp_devin"); - const userVariable = { name: "CLAUDE_CONFIG_DIR", value: "/tmp/allow-all", sensitive: false }; - const storedClaude = { - driver: claude, - environment: [userVariable, { ...userVariable, value: "/tmp/later-wins" }], - config: { - binaryPath: "/tmp/wrapper", - launchArgs: "--dangerously-skip-permissions", - homePath: "/tmp/allow-all", + const token = { name: "ANTHROPIC_API_KEY", value: "sk-kept", sensitive: true }; + return Effect.gen(function* () { + yield* withPolicy(undefined, (service) => + service.updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { driver: claude, environment: [token] }, + }), + ); + // The ceiling shows only pinned variables, so the client saves without the token. + yield* withPolicy(policy, (service) => + service.getSettings.pipe( + Effect.flatMap((settings) => + service.updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { + ...settings.providerInstances[claudeId]!, + displayName: "Claude (work)", + }, + }), + ), + ), + ); + const restored = yield* withPolicy(undefined, (service) => service.getSettings); + assert.equal(restored.providerInstances[claudeId]?.environment?.[0]?.value, token.value); + }).pipe(Effect.provide(shared)); + }); + + it.effect("ignores stored launch configuration and refuses new values", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const claudeId = ProviderInstanceId.make("claudeAgent"); + const registryId = ProviderInstanceId.make("acp_devin"); + const userVariable = { + name: "CLAUDE_CONFIG_DIR", + value: "/tmp/allow-all", + sensitive: false, + }; + const storedClaude = { + driver: claude, + environment: [userVariable, { ...userVariable, value: "/tmp/later-wins" }], + config: { + binaryPath: "/tmp/wrapper", + launchArgs: "--dangerously-skip-permissions", + homePath: "/tmp/allow-all", + autoCompactWindow: "300000", + }, + }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { + [claudeId]: storedClaude, + [registryId]: { + driver: acpRegistry, + config: { agentId: "devin", commandPath: "/tmp/agent", commandArgs: ["--yolo"] }, + }, + }, + }), + ); + + // Even an unlocked pin wins, and the user's launch values and variables are not used. + const effective = yield* service.getSettings; + const claudeInstance = effective.providerInstances[claudeId]!; + assert.deepEqual(claudeInstance.config, { + binaryPath: "/opt/claude/bin/claude", autoCompactWindow: "300000", - }, - }; - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ - providerInstances: { - [claudeId]: storedClaude, - [registryId]: { + }); + assert.deepEqual(claudeInstance.environment, [ + { name: "CLAUDE_CODE_USE_BEDROCK", value: "1", sensitive: false }, + ]); + assert.deepEqual(effective.providerInstances[registryId]?.config, { agentId: "devin" }); + + const refused = (instance: typeof claudeInstance) => + service + .updateProviderInstance({ operation: "upsert", instanceId: claudeId, instance }) + .pipe(Effect.flip); + const launchArgs = yield* refused({ + ...claudeInstance, + config: { ...claudeInstance.config!, launchArgs: "--permission-mode bypassPermissions" }, + }); + assert.equal(launchArgs.operation, "write-managed-setting"); + assert.equal(launchArgs.managedSetting, "providerInstances.claudeAgent.config.launchArgs"); + const binary = yield* refused({ + ...claudeInstance, + config: { ...claudeInstance.config!, binaryPath: "/tmp/other-wrapper" }, + }); + assert.equal(binary.managedSetting, "providerInstances.claudeAgent.config.binaryPath"); + const environment = yield* refused({ + ...claudeInstance, + environment: [{ name: "NODE_OPTIONS", value: "--require /tmp/x.js", sensitive: false }], + }); + assert.equal(environment.managedSetting, "providerInstances.claudeAgent.environment"); + const commandArgs = yield* service + .updateProviderInstance({ + operation: "upsert", + instanceId: registryId, + instance: { driver: acpRegistry, - config: { agentId: "devin", commandPath: "/tmp/agent", commandArgs: ["--yolo"] }, + config: { agentId: "devin", commandArgs: ["--dangerously-skip-permissions"] }, }, - }, - }), - ); + }) + .pipe(Effect.flip); + assert.equal(commandArgs.managedSetting, "providerInstances.acp_devin.config.commandArgs"); - // Even an unlocked pin wins, and the user's launch values and variables are not used. - const effective = yield* service.getSettings; - const claudeInstance = effective.providerInstances[claudeId]!; - assert.deepEqual(claudeInstance.config, { - binaryPath: "/opt/claude/bin/claude", - autoCompactWindow: "300000", - }); - assert.deepEqual(claudeInstance.environment, [ - { name: "CLAUDE_CODE_USE_BEDROCK", value: "1", sensitive: false }, - ]); - assert.deepEqual(effective.providerInstances[registryId]?.config, { agentId: "devin" }); + // Unrelated writes carry the stored instance through unchanged, duplicates included. + yield* service.updateSettings({ defaultAutoPull: true }); - const refused = (instance: typeof claudeInstance) => - service - .updateProviderInstance({ operation: "upsert", instanceId: claudeId, instance }) - .pipe(Effect.flip); - const launchArgs = yield* refused({ - ...claudeInstance, - config: { ...claudeInstance.config!, launchArgs: "--permission-mode bypassPermissions" }, - }); - assert.equal(launchArgs.operation, "write-managed-setting"); - assert.equal(launchArgs.managedSetting, "providerInstances.claudeAgent.config.launchArgs"); - const binary = yield* refused({ - ...claudeInstance, - config: { ...claudeInstance.config!, binaryPath: "/tmp/other-wrapper" }, - }); - assert.equal(binary.managedSetting, "providerInstances.claudeAgent.config.binaryPath"); - const environment = yield* refused({ - ...claudeInstance, - environment: [{ name: "NODE_OPTIONS", value: "--require /tmp/x.js", sensitive: false }], - }); - assert.equal(environment.managedSetting, "providerInstances.claudeAgent.environment"); - const commandArgs = yield* service - .updateProviderInstance({ + // What the client was shown saves, with user-owned edits, and the stored values survive. + yield* service.updateProviderInstance({ operation: "upsert", - instanceId: registryId, + instanceId: claudeId, instance: { - driver: acpRegistry, - config: { agentId: "devin", commandArgs: ["--dangerously-skip-permissions"] }, + ...claudeInstance, + displayName: "Claude (work)", + config: { ...claudeInstance.config!, autoCompactWindow: "400000" }, }, - }) - .pipe(Effect.flip); - assert.equal(commandArgs.managedSetting, "providerInstances.acp_devin.config.commandArgs"); - - // Unrelated writes carry the stored instance through unchanged, duplicates included. - yield* service.updateSettings({ defaultAutoPull: true }); - - // What the client was shown saves, with user-owned edits, and the stored values survive. - yield* service.updateProviderInstance({ - operation: "upsert", - instanceId: claudeId, - instance: { - ...claudeInstance, + }); + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(stored.providerInstances[claudeId], { + ...storedClaude, displayName: "Claude (work)", - config: { ...claudeInstance.config!, autoCompactWindow: "400000" }, - }, - }); - const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); - assert.deepEqual(stored.providerInstances[claudeId], { - ...storedClaude, - displayName: "Claude (work)", - config: { ...storedClaude.config, autoCompactWindow: "400000" }, - }); - }).pipe(Effect.provide(layerServerSettings(policy))), - ); -}); + config: { ...storedClaude.config, autoCompactWindow: "400000" }, + }); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + }, +); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 0b380ce2c10bd..7ecbf1bb877d6 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -15,6 +15,7 @@ import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { fxClient } from "@t3tools/provider-fx/client"; import { grokClient } from "@t3tools/provider-grok/client"; +import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -40,6 +41,7 @@ const providerSettingsSchemas = [ cursorClient, fxClient, grokClient, + kiroClient, museClient, openCodeClient, piClient, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 66a8fc4e94b0f..dedf3636df4ec 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,5 +1,6 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -98,7 +99,7 @@ function layer(input: { Layer.provide(Layer.succeed(FileSystem.FileSystem, input.fileSystem)), Layer.provideMerge(NodePath.layer), ) - : layerService.pipe(Layer.provideMerge(NodeServices.layer)); + : layerService.pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); } const lookupFailingWith = (cause: unknown, detail: string) => @@ -191,7 +192,7 @@ it.effect("clones a looked-up repository after creating missing parent directori }), ), ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["--bare", "source.git"])( @@ -225,7 +226,7 @@ it.effect.each(["--bare", "source.git"])( prefix: "t3-clone-options-config-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), @@ -288,7 +289,7 @@ it.effect("reports clone progress from git's stderr and keeps its error text on error.detail, "fatal: early EOF fatal: unable to access 'https://github.com/octocat/t3code.git/': could not resolve host", ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); function rejectingSsh( @@ -337,7 +338,7 @@ const cloneInTemporaryDirectory = ( destinationPath: path.join(parent, "t3code"), }); }).pipe(Effect.provide(layer({ git }).pipe(Layer.orDie))); - }).pipe(Effect.provide(NodeServices.layer)); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); it.effect("retries a clone over HTTPS when the SSH login is refused", () => Effect.gen(function* () { @@ -486,7 +487,7 @@ it.effect("strips embedded credentials from the remote URL it reports", () => ); assert.equal(result.remoteUrl, "https://github.com/octocat/t3code.git"); assert.equal(result.cloneUrl, "https://user:s3cret@github.com/octocat/t3code.git"); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("discards only a directory git wrote to", () => @@ -517,7 +518,7 @@ it.effect("discards only a directory git wrote to", () => assert.deepStrictEqual(yield* fs.readDirectory(partial), []); assert.deepStrictEqual(yield* fs.readDirectory(foreign), ["notes.txt"]); assert.strictEqual(yield* fs.readFileString(replaced), "not a directory"); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("redacts query tokens and userinfo containing '@' from reported URLs", () => @@ -538,7 +539,7 @@ it.effect("redacts query tokens and userinfo containing '@' from reported URLs", }); assert.equal(nested.remoteUrl, "https://github.com/octocat/t3code.git"); }).pipe(Effect.provide(layer({}))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("preserves destination probe failures instead of treating them as missing paths", () => { diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 44d31a43b6978..1e9c48893dad7 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -79,7 +79,8 @@ const CLONE_ENV = { // Force piped progress immediately; refuse credential prompts that would hang without a tty. GIT_PROGRESS_DELAY: "0", GIT_TERMINAL_PROMPT: "0", - LC_ALL: "C", + // English progress without overriding LC_CTYPE, which a global post-checkout hook inherits. + LANGUAGE: "C", } satisfies NodeJS.ProcessEnv; function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { diff --git a/apps/server/src/sourceControl/agentGitHubEnvironment.test.ts b/apps/server/src/sourceControl/agentGitHubEnvironment.test.ts index 1ee7685319fa0..74fcabb98a1fa 100644 --- a/apps/server/src/sourceControl/agentGitHubEnvironment.test.ts +++ b/apps/server/src/sourceControl/agentGitHubEnvironment.test.ts @@ -2,6 +2,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeURL from "node:url"; import * as NodeUtil from "node:util"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -10,7 +11,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { CommandResolutionCache } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -57,7 +58,7 @@ describe("managed GitHub CLI environment", () => { `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(entry)} "$@"\n`, ); yield* fs.chmod(path.join(realDir, "gh"), 0o755); - const services = yield* Effect.context(); + const services = yield* Effect.context(); const prepared = yield* mergeProviderInstanceEnvironment( [{ name: "PATH", value: providerPath, sensitive: false }], { PATH: "/not-installed", CALLS: calls, CUSTOM: "kept" }, @@ -142,7 +143,11 @@ describe("managed GitHub CLI environment", () => { expect(disabled.T3_GH_SHIM_DIR).toBeUndefined(); }).pipe( Effect.scoped, - Effect.provide(TestProviderHost.layer().pipe(Layer.provideMerge(NodeServices.layer))), + Effect.provide( + TestProviderHost.layer().pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), ), ); @@ -163,7 +168,7 @@ describe("managed GitHub CLI environment", () => { }), ).toEqual(environment); expect(yield* fs.exists(path.join(stateDir, "github"))).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([".EXE", ".CMD"])( @@ -194,7 +199,7 @@ describe("managed GitHub CLI environment", () => { Effect.scoped, Effect.provideService(HostProcess.Platform, "win32"), Effect.provideService(CommandResolutionCache, new Map()), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); }); diff --git a/apps/server/src/sourceControl/agentGitHubEnvironment.ts b/apps/server/src/sourceControl/agentGitHubEnvironment.ts index 0f559ac21d15f..093f2806c6474 100644 --- a/apps/server/src/sourceControl/agentGitHubEnvironment.ts +++ b/apps/server/src/sourceControl/agentGitHubEnvironment.ts @@ -1,7 +1,7 @@ import type { SelfInvocation } from "@t3tools/shared/nodeRuntime"; import { selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; import { resolveCommandPath } from "@t3tools/shared/shell"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Hex from "effect/encoding/Hex"; @@ -50,12 +50,14 @@ export const prepareAgentGitHubEnvironment = Effect.fn( if (realCommand === null) return environment; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const digestService = yield* Digest.Digest; const args = selfInvocationArgs(input.invocation, ["__github-cli", realCommand]); const source = windows ? `@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n${[input.invocation.command, ...args].map(quoteBatch).join(" ")} %*\r\n` : `#!/bin/sh\nexport ELECTRON_RUN_AS_NODE=1\nexec ${[input.invocation.command, ...args].map(quoteShell).join(" ")} "$@"\n`; - const digest = Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(source))); + const digest = Hex.encode( + yield* digestService.digest(Digest.sha256, new TextEncoder().encode(source)), + ); const directory = path.join(input.stateDir, "github", "bin", digest); const launcher = path.join(directory, windows ? "gh.cmd" : "gh"); // Immutable launchers allow concurrent provider instances with different gh installations. diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index bfbe4a1c8ac94..be1da6f3d610c 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -14,7 +14,14 @@ const source = (reference: string) => ({ reference, }); -const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); +const secretsByReference = new Map([ + ["bitbucket#token", "bb_from_provider"], + ["github#token", "ghp_from_provider"], +]); + +const perHostHosts = { + github: { tokens: { "github.com": source("github#token"), "ghe.acme.test": "ghp_literal" } }, +}; const hosts = { bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, @@ -69,11 +76,25 @@ it.effect("leaves the resolver unused for a kind that holds no reference", () => }), ); +it.effect("replaces a per-host token reference and keeps the literal beside it", () => + Effect.gen(function* () { + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, perHostHosts, "github"); + assert.deepEqual(resolved, { + github: { tokens: { "github.com": "ghp_from_provider", "ghe.acme.test": "ghp_literal" } }, + }); + assert.deepEqual(reads, ["github#token"]); + }), +); + it("collects the references held in credential fields and skips literal values", () => { assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [ source("missing#token"), source("bitbucket#token"), ]); + assert.deepEqual(collectSourceControlHostSecretReferences(perHostHosts), [ + source("github#token"), + ]); assert.deepEqual( collectSourceControlHostSecretReferences({ bitbucket: { accessToken: "literal" }, diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index cf86ade6b0eee..82de25be4de69 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -53,7 +53,16 @@ export function writeSecretFieldValues( ): unknown { if (typeof original === "string") return values[0]?.[1] ?? ""; if (isSecretReference(original)) return original; - return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); + const sources = + original !== null && typeof original === "object" && !Array.isArray(original) + ? Object.entries(original).flatMap(([serverHost, entry]) => + isSecretReference(entry) ? [[serverHost.trim().toLowerCase(), entry] as const] : [], + ) + : []; + return Object.fromEntries([ + ...sources, + ...values.map(([serverHost, value]) => [serverHost ?? "", value] as const), + ]); } type SourceControlHostsSettings = Readonly>>>; diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 4cecdaa9f1924..937af138d9db1 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Context from "effect/Context"; @@ -580,7 +581,7 @@ const pausedMeasurementFixture = (storageCleanup?: StorageCleanupSettings) => }); const cleanupTestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const runCleanupTest = ( effect: Effect.Effect< diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 1cc30e74e504d..98e3d640c604c 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -1,4 +1,5 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Clock from "effect/Clock"; @@ -169,7 +170,7 @@ const sendFor = ( return batches; }); -it.layer(NodeServices.layer)("AnalyticsService test", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService test", (it) => { it.effect("waits for desktop shell consent and honors a late telemetry opt-out", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" }; diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 05d82c7aa5cf0..91b00eeb12335 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -5,7 +5,7 @@ import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -144,7 +144,7 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; const { distinctId, installId } = yield* getTelemetryIdentity; - const crypto = yield* Crypto.Crypto; + const crypto = yield* SecureRandom.SecureRandom; const bufferRef = yield* Ref.make>([]); const deliveryRef = yield* Ref.make({ failedBatch: [], @@ -312,7 +312,7 @@ export const make = Effect.gen(function* () { if (!(yield* telemetryEnabled) || !distinctId) return; // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. - const uuid = yield* Effect.exit(crypto.randomUUIDv7()); + const uuid = yield* Effect.exit(crypto.uuidV7()); if (Exit.isFailure(uuid)) return; const enqueueResult = yield* enqueueBufferedEvent(uuid.value, event, properties); diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index 6fa6c8500817e..6a0951875106f 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -1,6 +1,7 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -21,9 +22,9 @@ interface CapturedLog { } const sha256 = Effect.fn("test.sha256")(function* (value: string) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; return Hex.encode( - yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie), + yield* digest.digest(Digest.sha256, new TextEncoder().encode(value)).pipe(Effect.orDie), ); }); @@ -69,7 +70,7 @@ const writeHomeFile = Effect.fn("test.writeHomeFile")(function* ( return filePath; }); -it.layer(NodeServices.layer)("telemetry identity", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("telemetry identity", (it) => { it.effect("keeps the old distinct_id rule and a fixed install id as logins change", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 0baebc1e1803d..7ea7e9d296771 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,4 +1,4 @@ -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as FileSystem from "effect/FileSystem"; @@ -225,8 +225,8 @@ const upsertAnonymousId = Effect.gen(function* () { return existing.value; } - const anonymousId = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), + const anonymousId = yield* SecureRandom.SecureRandom.pipe( + Effect.flatMap((crypto) => crypto.uuidV4()), Effect.mapError( (cause) => new TelemetryAnonymousIdGenerationError({ diff --git a/apps/server/src/telemetry/ProviderDimensions.ts b/apps/server/src/telemetry/ProviderDimensions.ts index 4e0447a1e2c44..f81a22e844aef 100644 --- a/apps/server/src/telemetry/ProviderDimensions.ts +++ b/apps/server/src/telemetry/ProviderDimensions.ts @@ -4,6 +4,8 @@ import type { ServerProvider, } from "@t3tools/contracts"; +import { BUNDLED_MODEL_MANIFEST } from "../provider/ModelManifest.ts"; + /** * Drivers whose model catalogs are published by the vendor. Other drivers * (OpenCode, Pi, ACP agents) list models from the user's own configuration, @@ -39,6 +41,44 @@ export function providerDimensions( : { provider: provider.driver }; } +const DATED_SNAPSHOT = /-\d{8}$/u; + +// The bundled manifest is reviewed in this repo. Codex lists whatever its configured +// model provider serves, so only the manifest vouches for Codex, Claude, and Antigravity. +const MANIFEST_LABELS: ReadonlyMap = new Map( + Object.values(BUNDLED_MODEL_MANIFEST.providers ?? {}).flatMap((catalog) => + catalog.models.flatMap((model) => + [model.slug, ...(model.aliases ?? [])].map((name) => [name, model.slug] as const), + ), + ), +); +const REGISTRY_LABEL_DRIVERS: ReadonlySet = new Set(["cursor", "grok"]); + +/** + * The vendor catalog slug that a native or routed model id names, such as + * `anthropic/claude-opus-5-5` or a dated snapshot. Models outside the bundled + * manifest and the Cursor and Grok catalogs, including user-added ones, return undefined. + */ +export function catalogModelLabel( + providers: ReadonlyArray, + model: string, +): string | undefined { + const leaf = model.slice(model.lastIndexOf("/") + 1); + const candidates = [model, leaf, leaf.replace(DATED_SNAPSHOT, "")]; + for (const candidate of candidates) { + const label = MANIFEST_LABELS.get(candidate); + if (label !== undefined) return label; + } + for (const provider of providers) { + if (!REGISTRY_LABEL_DRIVERS.has(provider.driver)) continue; + const match = provider.models.find( + (entry) => !entry.isCustom && candidates.includes(entry.slug), + ); + if (match !== undefined) return match.slug; + } + return undefined; +} + /** The thread fields agent analytics reads. Ids are used for lookups, never reported. */ export type AgentThread = Pick< OrchestrationV2ThreadShell, diff --git a/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts b/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts new file mode 100644 index 0000000000000..bd0a7c7da64b9 --- /dev/null +++ b/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts @@ -0,0 +1,350 @@ +import { + NodeId, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + RunId, + type OrchestrationV2ProviderTurn, + type ServerProvider, + type TurnTokenUsage, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as AnalyticsService from "./AnalyticsService.ts"; +import { catalogModelLabel } from "./ProviderDimensions.ts"; +import { + ProviderTurnAnalytics, + RequestUsageLedger, + layer, + providerTurnProperties, + type ProviderTurnAnalyticsContext, + type ProviderTurnSegment, +} from "./ProviderUsageAnalytics.ts"; + +const providers = [ + // Codex lists whatever its configured model provider serves, including private deployments. + { driver: "codex", models: [{ slug: "gpt-6-sol" }, { slug: "acme-azure-deployment" }] }, + { driver: "claudeAgent", models: [{ slug: "claude-opus-5-5" }, { slug: "claude-haiku-4-5" }] }, + { driver: "opencode", models: [{ slug: "ollama/acme-internal" }] }, +].map( + ({ driver, models }) => + ({ + instanceId: ProviderInstanceId.make(driver), + driver, + models: models.map((model) => ({ ...model, isCustom: false })), + }) as unknown as ServerProvider, +); +const label = (model: string) => catalogModelLabel(providers, model); + +const at = (ms: number) => DateTime.makeUnsafe(ms); +const complete = (input: number, output: number, extra: Partial = {}) => + ({ + usageStatus: "complete", + usageScope: "main_agent", + hasSubagents: false, + inputTokens: input, + outputTokens: output, + ...extra, + }) as TurnTokenUsage; + +const turn = ( + id: string, + usage: TurnTokenUsage | undefined, + status: OrchestrationV2ProviderTurn["status"] = "completed", +): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(id), + providerThreadId: ProviderThreadId.make("provider-thread"), + nodeId: NodeId.make(`node:${id}`), + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status, + startedAt: at(1_000), + completedAt: at(4_000), + ...(usage === undefined ? {} : { turnTokenUsage: usage }), +}); + +const context = ( + runId: string, + segment: ProviderTurnSegment, + model = "gpt-6-sol", +): ProviderTurnAnalyticsContext => ({ + runId: RunId.make(runId), + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model, + options: [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "serviceTier", value: "priority" }, + ], + }, + runtimeMode: "full-access", + interactionMode: "default", + origin: { createdBy: "user", creationSource: "web", scheduled: false, subagent: false }, + segment, +}); + +const settled = (runId: string, childrenRunning = false) => ({ + runId: RunId.make(runId), + status: "completed" as const, + completedAt: at(9_000), + childrenRunning, +}); + +describe("provider usage reporting", () => { + it.effect( + "records one request across a continuation, a child, a replay, and a late child", + () => { + const recorded: Array = []; + const layerTest = layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(AnalyticsService.AnalyticsService)({ + record: (event, properties) => + Effect.sync(() => void recorded.push([event, properties])), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(providers), + }), + ), + ), + ); + return Effect.gen(function* () { + const analytics = yield* ProviderTurnAnalytics; + const root = context("run-a", "initial"); + yield* analytics.runStarted({ runId: RunId.make("run-a"), driver: "codex", context: root }); + const first = { driver: "codex", context: root }; + const firstTurn = turn("root", complete(100, 10, { models: ["gpt-6-sol"] })); + yield* analytics.turnCompleted({ ...first, providerTurn: firstTurn }); + yield* analytics.turnCompleted({ ...first, providerTurn: firstTurn }); + yield* analytics.turnCompleted({ + driver: "codex", + context: context("run-a", "continuation"), + providerTurn: turn("goal", complete(50, 5, { models: ["gpt-6-sol"] })), + }); + yield* analytics.turnCompleted({ + driver: "codex", + context: context("run-a", "native_child"), + providerTurn: turn("child", complete(20, 2)), + }); + yield* analytics.runSettled(settled("run-a")); + yield* analytics.turnCompleted({ + driver: "codex", + context: context("run-a", "native_child"), + providerTurn: turn("late-child", complete(7, 1)), + }); + yield* analytics.runSettled(settled("run-a")); + yield* analytics.runStarted({ + runId: RunId.make("run-b"), + driver: "codex", + context: context("run-b", "initial"), + }); + yield* analytics.runSettled(settled("run-b")); + + const requests = recorded.filter(([event]) => event === "provider.request.completed"); + expect(recorded.filter(([event]) => event === "provider.turn.completed")).toHaveLength(5); + expect(requests).toHaveLength(2); + // The whole payload: no run, turn, or thread ids, prompts, or raw model names. + expect(requests[0]?.[1]).toEqual({ + reportVersion: 2, + provider: "codex", + requestKind: "user", + terminalStatus: "completed", + lifecycleObserved: true, + model: "gpt-6-sol", + effort: "xhigh", + serviceTier: "fast", + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "web", + scheduled: false, + subagent: false, + providerTurns: 2, + continuations: 1, + steeringRestarts: 0, + usageStatus: "complete", + inputTokens: 150, + outputTokens: 15, + nativeChildTurns: 1, + childUsageStatus: "complete", + childInputTokens: 20, + childOutputTokens: 2, + durationMs: 8_000, + }); + expect(requests[1]?.[1]).toMatchObject({ providerTurns: 0, usageStatus: "unavailable" }); + }).pipe(Effect.provide(layerTest)); + }, + ); + + it("keeps a request partial when its start, a steered segment, or a counter is missing", () => { + const ledger = new RequestUsageLedger(); + ledger.start(RunId.make("restarted"), "codex", { + ...context("restarted", "initial"), + restartContinuation: true, + }); + ledger.add({ + driver: "codex", + context: context("restarted", "initial"), + providerTurn: turn("after-restart", complete(10, 1)), + }); + expect(ledger.settle(settled("restarted"), label)).toMatchObject({ + restartContinuation: true, + lifecycleObserved: false, + usageStatus: "partial", + inputTokens: 10, + }); + + ledger.start(RunId.make("steered"), "claudeAgent", context("steered", "initial")); + ledger.add({ + driver: "claudeAgent", + context: context("steered", "initial"), + providerTurn: turn( + "steered-away", + { usageStatus: "partial", usageScope: "main_agent", hasSubagents: false, outputTokens: 4 }, + "interrupted", + ), + }); + ledger.add({ + driver: "claudeAgent", + context: context("steered", "steering_restart"), + providerTurn: turn("steered-to", complete(30, 6)), + }); + expect(ledger.settle(settled("steered"), label)).toMatchObject({ + usageStatus: "partial", + steeringRestarts: 1, + inputTokens: 30, + outputTokens: 10, + }); + + ledger.start(RunId.make("silent"), "grok", context("silent", "initial")); + ledger.add({ + driver: "grok", + context: context("silent", "initial"), + providerTurn: turn("silent", undefined), + }); + const silent = ledger.settle(settled("silent", true), label); + expect(silent).toMatchObject({ + usageStatus: "unavailable", + childrenRunning: true, + childUsageStatus: "unavailable", + }); + expect(silent).not.toHaveProperty("outputTokens"); + }); + + it("confirms only the models a provider reported", () => { + const opus = context("models", "initial", "claude-opus-5-5"); + const properties = (usage: TurnTokenUsage | undefined) => + providerTurnProperties( + { driver: "claudeAgent", context: opus, providerTurn: turn("t", usage) }, + label, + ); + expect(properties(complete(1, 1, { models: ["claude-haiku-4-5-20251001"] }))).toMatchObject({ + model: "claude-opus-5-5", + observedModel: "claude-haiku-4-5", + mixedModels: false, + }); + const unconfirmed = properties(complete(1, 1)); + expect(unconfirmed).toMatchObject({ model: "claude-opus-5-5" }); + expect(unconfirmed).not.toHaveProperty("mixedModels"); + expect(properties(complete(1, 1, { models: ["a", "b"] }))).toMatchObject({ mixedModels: true }); + + const ledger = new RequestUsageLedger(); + ledger.start(RunId.make("mixed"), "codex", context("mixed", "initial")); + ledger.add({ + driver: "codex", + context: context("mixed", "initial"), + providerTurn: turn("root", complete(1, 1, { models: ["gpt-6-sol"] })), + }); + ledger.add({ + driver: "codex", + context: context("mixed", "native_child"), + providerTurn: turn("child", complete(1, 1, { models: ["gpt-6-luna"] })), + }); + expect(ledger.settle(settled("mixed"), label)).toMatchObject({ mixedModels: true }); + }); + + it("keeps zero, absent, local-command, and ledger usage distinct", () => { + const properties = (usage: TurnTokenUsage | undefined) => + providerTurnProperties( + { driver: "claudeAgent", context: context("z", "initial"), providerTurn: turn("t", usage) }, + label, + ); + expect(properties(complete(0, 0))).toMatchObject({ + usageStatus: "complete", + usageReported: true, + inputTokens: 0, + outputTokens: 0, + }); + const absent = properties(undefined); + expect(absent).toMatchObject({ usageStatus: "unavailable", usageReported: false }); + expect(absent).not.toHaveProperty("outputTokens"); + expect( + properties( + complete(0, 0, { localCommand: true, otherUsage: { inputTokens: 900, outputTokens: 80 } }), + ), + ).toMatchObject({ localCommand: true, otherInputTokens: 900, otherOutputTokens: 80 }); + expect(properties(complete(74_167, 236, { reasoningTokens: 30 }))).toMatchObject({ + outputTokens: 236, + reasoningTokens: 30, + }); + }); + + it("reports routed catalog models and closed efforts only", () => { + const routed = (model: string, variant: string) => + providerTurnProperties( + { + driver: "opencode", + context: { + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model, + options: [{ id: "variant", value: variant }], + }, + }, + providerTurn: turn("t", complete(1, 1, { models: [model] })), + }, + label, + ); + expect(routed("my-proxy/claude-opus-5-5", "high")).toMatchObject({ + model: "claude-opus-5-5", + observedModel: "claude-opus-5-5", + effort: "high", + }); + for (const local of [ + routed("ollama/acme-internal", "secret-variant"), + routed("acme-azure-deployment", "high"), + ]) { + expect(local).not.toHaveProperty("model"); + expect(local).not.toHaveProperty("observedModel"); + } + expect(routed("ollama/acme-internal", "secret-variant")).not.toHaveProperty("effort"); + }); + + it("reports closed service tiers from a tier option or a fast mode toggle", () => { + const tier = (option: { id: string; value: string | boolean }) => + providerTurnProperties( + { + driver: "codex", + context: { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [option], + }, + }, + providerTurn: turn("t", complete(1, 1)), + }, + label, + ).serviceTier; + expect(tier({ id: "serviceTier", value: "ultrafast" })).toBe("ultrafast"); + expect(tier({ id: "serviceTier", value: "default" })).toBe("standard"); + expect(tier({ id: "serviceTier", value: "/home/me/secret" })).toBeUndefined(); + expect(tier({ id: "fastMode", value: true })).toBe("fast"); + expect(tier({ id: "fastMode", value: false })).toBe("standard"); + }); +}); diff --git a/apps/server/src/telemetry/ProviderUsageAnalytics.ts b/apps/server/src/telemetry/ProviderUsageAnalytics.ts new file mode 100644 index 0000000000000..ce7f29ba1f8d5 --- /dev/null +++ b/apps/server/src/telemetry/ProviderUsageAnalytics.ts @@ -0,0 +1,427 @@ +/** + * Provider token reporting: one `provider.turn.completed` per provider turn and one + * `provider.request.completed` per run, built from the same turns so each counts once. + * + * @module ProviderUsageAnalytics + */ +import type { + ModelSelection, + OrchestrationV2Actor, + OrchestrationV2CreationSource, + OrchestrationV2ProviderTurn, + OrchestrationV2RunAttempt, + ProviderInteractionMode, + RunId, + RuntimeMode, + TurnTokenUsage, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as AnalyticsService from "./AnalyticsService.ts"; +import { catalogModelLabel } from "./ProviderDimensions.ts"; + +// Bump when a field changes meaning, so reports can split old and new rows. +const REPORT_VERSION = 2; + +/** + * Where the run's starting message came from. Most turns start without a + * person (provider continuations, agent sends, delegated subagents), so + * reports need this to tell user turns apart. + */ +export interface ProviderTurnOrigin { + readonly createdBy: OrchestrationV2Actor; + readonly creationSource: OrchestrationV2CreationSource; + readonly scheduled: boolean; + readonly subagent: boolean; +} + +/** What started this provider turn inside its run. */ +export type ProviderTurnSegment = + | OrchestrationV2RunAttempt["reason"] + | "continuation" + | "native_child"; + +export interface ProviderTurnAnalyticsContext { + readonly runId?: RunId; + readonly modelSelection: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly origin?: ProviderTurnOrigin; + readonly segment?: ProviderTurnSegment; + readonly restartContinuation?: boolean; +} + +export interface ProviderTurnReport { + readonly driver: string; + readonly providerTurn: OrchestrationV2ProviderTurn; + readonly context?: ProviderTurnAnalyticsContext; +} + +export interface RunSettledReport { + readonly runId: RunId; + readonly status: OrchestrationV2ProviderTurn["status"]; + readonly completedAt: DateTime.Utc; + // Run-owned children were still open when the run settled; their later turns are not added. + readonly childrenRunning: boolean; +} + +type Properties = Readonly>; +/** Maps a native model id to a vendor catalog label, or undefined to keep it out. */ +export type ModelLabeler = (model: string) => string | undefined; + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "interrupted", + "cancelled", +]); + +const REPORTED_EFFORTS: ReadonlySet = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultrathink", + "ultracode", + "brutal", +]); + +const defined = (entries: Properties): Properties => + Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined)); + +// OpenCode names its effort option `variant`, and variants can be user-defined. +function selectedEffort(selection: ModelSelection): string | undefined { + const effort = + getModelSelectionStringOptionValue(selection, "reasoningEffort") ?? + getModelSelectionStringOptionValue(selection, "effort") ?? + getModelSelectionStringOptionValue(selection, "variant"); + return effort !== undefined && REPORTED_EFFORTS.has(effort) ? effort : undefined; +} + +// Codex sends a tier id (its "fast" config shows up as "priority"); Claude has a fastMode toggle. +const REPORTED_SERVICE_TIERS: ReadonlyMap = new Map([ + ["default", "standard"], + ["flex", "flex"], + ["fast", "fast"], + ["priority", "fast"], + ["ultrafast", "ultrafast"], +]); + +function selectedServiceTier(selection: ModelSelection): string | undefined { + const tier = getModelSelectionStringOptionValue(selection, "serviceTier"); + if (tier !== undefined) return REPORTED_SERVICE_TIERS.get(tier); + const fastMode = getModelSelectionBooleanOptionValue(selection, "fastMode"); + return fastMode === undefined ? undefined : fastMode ? "fast" : "standard"; +} + +/** + * Confirmed models across counted work. Unknown when any piece of work has no + * reported model, unless two distinct models were already seen. + */ +function modelObservation( + models: ReadonlyArray | undefined>, + label: ModelLabeler, +): Properties { + const distinct = new Set(models.flatMap((entry) => entry ?? [])); + if (distinct.size > 1) return { mixedModels: true }; + if (distinct.size === 0 || models.some((entry) => entry === undefined || entry.length === 0)) { + return {}; + } + return defined({ mixedModels: false, observedModel: label([...distinct][0]!) }); +} + +function selectionProperties( + context: ProviderTurnAnalyticsContext | undefined, + label: ModelLabeler, +) { + const selection = context?.modelSelection; + return defined({ + model: selection === undefined ? undefined : label(selection.model), + effort: selection === undefined ? undefined : selectedEffort(selection), + serviceTier: selection === undefined ? undefined : selectedServiceTier(selection), + runtimeMode: context?.runtimeMode, + interactionMode: context?.interactionMode, + ...context?.origin, + }); +} + +function childKind(context: ProviderTurnAnalyticsContext | undefined) { + if (context?.segment === "native_child") return "native"; + return context?.origin?.subagent === true ? "t3" : undefined; +} + +function counters(usage: TurnTokenUsage | undefined): Properties { + return defined({ + inputTokens: usage?.inputTokens, + cachedInputTokens: usage?.cachedInputTokens, + cacheCreationTokens: usage?.cacheCreationTokens, + outputTokens: usage?.outputTokens, + reasoningTokens: usage?.reasoningTokens, + }); +} + +export function providerTurnProperties( + report: ProviderTurnReport, + label: ModelLabeler, +): Properties { + const { providerTurn, context } = report; + const usage = providerTurn.turnTokenUsage; + return defined({ + reportVersion: REPORT_VERSION, + provider: report.driver, + terminalStatus: providerTurn.status, + usageStatus: usage?.usageStatus ?? "unavailable", + usageScope: usage?.usageScope ?? "main_agent", + usageReported: usage !== undefined, + hasSubagents: usage?.hasSubagents, + ...counters(usage), + localCommand: usage?.localCommand === true ? true : undefined, + otherInputTokens: usage?.otherUsage?.inputTokens, + otherOutputTokens: usage?.otherUsage?.outputTokens, + ...selectionProperties(context, label), + ...modelObservation([usage?.models], label), + segment: context?.segment, + childKind: childKind(context), + durationMs: + providerTurn.startedAt && providerTurn.completedAt + ? Math.max( + 0, + DateTime.toEpochMillis(providerTurn.completedAt) - + DateTime.toEpochMillis(providerTurn.startedAt), + ) + : undefined, + }); +} + +/** How the request reached the provider; only `user` counts as a person asking. */ +function requestKind(origin: ProviderTurnOrigin | undefined) { + if (origin === undefined) return "unknown"; + if (origin.scheduled) return "scheduled"; + if (origin.subagent) return "t3_child"; + if (origin.createdBy === "user") return "user"; + if (origin.creationSource === "provider") return "provider_continuation"; + return origin.createdBy; +} + +function sumOf( + usages: ReadonlyArray, + field: + | "inputTokens" + | "cachedInputTokens" + | "cacheCreationTokens" + | "outputTokens" + | "reasoningTokens", + requireAll: boolean, +): number | undefined { + const values = usages.map((usage) => usage[field]); + const known = values.filter((value) => value !== undefined); + if (known.length === 0 || (requireAll && known.length < values.length)) return undefined; + return known.reduce((sum, value) => sum + value, 0); +} + +/** complete only when every turn is complete; partial when any turn has counts. */ +function combinedStatus(usages: ReadonlyArray) { + if (usages.length > 0 && usages.every((usage) => usage?.usageStatus === "complete")) { + return "complete"; + } + return usages.some( + (usage) => usage?.inputTokens !== undefined || usage?.outputTokens !== undefined, + ) + ? "partial" + : "unavailable"; +} + +interface RequestTurn { + readonly child: boolean; + readonly segment: ProviderTurnSegment | undefined; + readonly providerTurn: OrchestrationV2ProviderTurn; +} + +interface RequestEntry { + driver: string | undefined; + context: ProviderTurnAnalyticsContext | undefined; + readonly lifecycleObserved: boolean; + readonly turns: Map; +} + +const MAX_TRACKED_RUNS = 2048; + +/** + * Collects terminal provider turns per run in this process. A replayed turn + * replaces its earlier copy, a settled run ignores late turns, and a run whose + * initial attempt started before this process reports `lifecycleObserved: false`. + */ +export class RequestUsageLedger { + private readonly open = new Map(); + private readonly settled = new Set(); + + start(runId: RunId, driver: string, context: ProviderTurnAnalyticsContext): void { + if (this.settled.has(runId) || this.open.has(runId)) return; + // A restart continuation carries on a run whose earlier turns this process never saw. + const lifecycleObserved = context.restartContinuation !== true; + this.open.set(runId, { driver, context, lifecycleObserved, turns: new Map() }); + evictOldest(this.open); + } + + add(report: ProviderTurnReport): void { + const runId = report.context?.runId; + if (runId === undefined || this.settled.has(runId)) return; + if (!TERMINAL_STATUSES.has(report.providerTurn.status)) return; + let entry = this.open.get(runId); + if (entry === undefined) { + entry = { driver: undefined, context: undefined, lifecycleObserved: false, turns: new Map() }; + this.open.set(runId, entry); + evictOldest(this.open); + } + const child = report.context?.segment === "native_child"; + if (!child) { + entry.driver ??= report.driver; + entry.context ??= report.context; + } + entry.turns.set(String(report.providerTurn.id), { + child, + segment: report.context?.segment, + providerTurn: report.providerTurn, + }); + } + + settle(report: RunSettledReport, label: ModelLabeler): Properties | undefined { + if (this.settled.has(report.runId)) return undefined; + this.settled.add(report.runId); + evictOldest(this.settled); + const entry = this.open.get(report.runId); + this.open.delete(report.runId); + if (entry === undefined) return undefined; + + const turns = [...entry.turns.values()]; + const root = turns.filter((turn) => !turn.child); + const children = turns.filter((turn) => turn.child); + const rootUsage = root.map((turn) => turn.providerTurn.turnTokenUsage); + const childUsage = children.map((turn) => turn.providerTurn.turnTokenUsage); + const { lifecycleObserved } = entry; + const measuredRoot = rootUsage.filter((usage) => usage !== undefined); + const status = combinedStatus(rootUsage); + const rootComplete = status === "complete"; + const otherUsage = measuredRoot.map((usage) => usage.otherUsage); + const allOtherKnown = otherUsage.length > 0 && otherUsage.every((usage) => usage !== undefined); + const anyRootSubagents = measuredRoot.some((usage) => usage.hasSubagents); + const measuredChildren = childUsage.filter((usage) => usage !== undefined); + const childStatus = + children.length === 0 && !anyRootSubagents && !report.childrenRunning + ? "none" + : children.length === 0 + ? "unavailable" + : report.childrenRunning && combinedStatus(childUsage) === "complete" + ? "partial" + : combinedStatus(childUsage); + const startedAt = turns + .map((turn) => turn.providerTurn.startedAt) + .filter((value) => value !== null) + .map(DateTime.toEpochMillis); + + return defined({ + reportVersion: REPORT_VERSION, + provider: entry.driver ?? "unknown", + requestKind: requestKind(entry.context?.origin), + restartContinuation: entry.context?.restartContinuation === true ? true : undefined, + terminalStatus: report.status, + lifecycleObserved, + ...selectionProperties(entry.context, label), + providerTurns: root.length, + continuations: root.filter((turn) => turn.segment === "continuation").length, + steeringRestarts: root.filter((turn) => turn.segment === "steering_restart").length, + usageStatus: lifecycleObserved || status === "unavailable" ? status : "partial", + inputTokens: sumOf(measuredRoot, "inputTokens", rootComplete), + cachedInputTokens: sumOf(measuredRoot, "cachedInputTokens", true), + cacheCreationTokens: sumOf(measuredRoot, "cacheCreationTokens", true), + outputTokens: sumOf(measuredRoot, "outputTokens", rootComplete), + reasoningTokens: sumOf(measuredRoot, "reasoningTokens", true), + localCommand: measuredRoot.some((usage) => usage.localCommand === true) ? true : undefined, + otherInputTokens: allOtherKnown + ? otherUsage.reduce((sum, usage) => sum + (usage?.inputTokens ?? 0), 0) + : undefined, + otherOutputTokens: allOtherKnown + ? otherUsage.reduce((sum, usage) => sum + (usage?.outputTokens ?? 0), 0) + : undefined, + nativeChildTurns: children.length, + childUsageStatus: childStatus, + childInputTokens: sumOf(measuredChildren, "inputTokens", false), + childOutputTokens: sumOf(measuredChildren, "outputTokens", false), + childrenRunning: report.childrenRunning ? true : undefined, + ...modelObservation( + [...rootUsage, ...childUsage].map((usage) => usage?.models), + label, + ), + durationMs: + startedAt.length === 0 + ? undefined + : Math.max(0, DateTime.toEpochMillis(report.completedAt) - Math.min(...startedAt)), + }); + } +} + +function evictOldest(collection: Map | Set) { + if (collection.size <= MAX_TRACKED_RUNS) return; + const oldest = collection.keys().next().value; + if (oldest !== undefined) collection.delete(oldest); +} + +export class ProviderTurnAnalytics extends Context.Reference<{ + readonly runStarted: (input: { + readonly runId: RunId; + readonly driver: string; + readonly context: ProviderTurnAnalyticsContext; + }) => Effect.Effect; + readonly turnCompleted: (report: ProviderTurnReport) => Effect.Effect; + readonly runSettled: (report: RunSettledReport) => Effect.Effect; +}>("t3/telemetry/ProviderTurnAnalytics", { + defaultValue: () => ({ + runStarted: () => Effect.void, + turnCompleted: () => Effect.void, + runSettled: () => Effect.void, + }), +}) {} + +export const layer = Layer.effect( + ProviderTurnAnalytics, + Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + const registry = yield* ProviderRegistry.ProviderRegistry; + const ledger = new RequestUsageLedger(); + const labeler = Effect.map( + registry.getProviders, + (providers): ModelLabeler => + (model) => + catalogModelLabel(providers, model), + ); + return { + runStarted: Effect.fn("ProviderTurnAnalytics.runStarted")((input) => + Effect.sync(() => ledger.start(input.runId, input.driver, input.context)), + ), + turnCompleted: Effect.fn("ProviderTurnAnalytics.turnCompleted")(function* (report) { + ledger.add(report); + yield* analytics.record( + "provider.turn.completed", + providerTurnProperties(report, yield* labeler), + ); + }), + runSettled: Effect.fn("ProviderTurnAnalytics.runSettled")(function* (report) { + const properties = ledger.settle(report, yield* labeler); + if (properties !== undefined) { + yield* analytics.record("provider.request.completed", properties); + } + }), + }; + }), +); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index d6358cc558c63..1b7a9f59cfa7c 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -410,7 +411,11 @@ it("bounds long partial lines and joins surrogate pairs across chunk boundaries" }); it.layer( - Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + ProcessRunner.layer.pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ), { excludeTestServices: true }, )("TerminalManager", (it) => { it.effect("spawns lazily and reuses running terminal per thread", () => diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index c74f77db4d061..26ab9e79c7103 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -4,6 +4,7 @@ import * as NodeOS from "node:os"; import * as NodeURL from "node:url"; import * as NodeFS from "node:fs"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -25,7 +26,7 @@ const mockAgentPath = NodePath.join(__dirname, "../provider/testFixtures/grok-te const layerGrokTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-grok-text-generation-test-", -}).pipe(Layer.provideMerge(NodeServices.layer)); +}).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); function makeAcpGrokWrapper(dir: string, env: Record): string { return writeFakeCli({ diff --git a/apps/server/src/triage/TriageService.test.ts b/apps/server/src/triage/TriageService.test.ts index 2b75c70b162c3..8ae0ca0687173 100644 --- a/apps/server/src/triage/TriageService.test.ts +++ b/apps/server/src/triage/TriageService.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { GitCommandError } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; @@ -39,7 +40,7 @@ it("falls back to main when the server's version has no tag", () => { const TestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-triage-service-" })), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const RELEASE_COMMIT = "7027b1f"; diff --git a/apps/server/src/triage/TriageService.ts b/apps/server/src/triage/TriageService.ts index de65ebc164613..453a0d881939b 100644 --- a/apps/server/src/triage/TriageService.ts +++ b/apps/server/src/triage/TriageService.ts @@ -24,7 +24,8 @@ const T3CODE_REPOSITORY_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}`; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", - LC_ALL: "C", + // English messages without overriding LC_CTYPE, which a global post-checkout hook inherits. + LANGUAGE: "C", } satisfies NodeJS.ProcessEnv; /** Starts in-app triage sessions: the same files `t3 triage` writes, plus a source clone. */ diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index d6078e9b4500c..869b6aa9cefbf 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -9,6 +9,7 @@ import * as NodeZlib from "node:zlib"; import { assert, describe, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { mergeUsage } from "@t3tools/shared/usageMerge"; @@ -30,6 +31,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Context from "effect/Context"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -67,12 +69,17 @@ const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unkno const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); -function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { +function claudeLine( + id: number, + outputTokens: number, + model = "claude-fable-5", + sessionId = "session-1", +): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", requestId: `req_${id}`, - sessionId: "session-1", + sessionId, message: { id: `msg_${id}`, model, @@ -144,6 +151,7 @@ const layerService = (input: { readonly environment?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; readonly hostname?: string; + readonly secretStore?: Partial; }) => layerCursorUsageAccounts( CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), @@ -153,11 +161,16 @@ const layerService = (input: { Layer.provideMerge(ProviderHostLive.layer), Layer.provideMerge(CodexUsageHistory.layer.pipe(Layer.provideMerge(Sqlite.layerMemory))), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), - Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provideMerge( + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: () => Effect.succeedNone, + ...input.secretStore, + }), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), NodePath.join(input.home, input.prefix)), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(Layer.succeed(HostProcess.Hostname, input.hostname ?? "usage-test-host")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), @@ -234,10 +247,12 @@ function makeFakeCursor() { error: null as string | null, gate: undefined as Deferred.Deferred | undefined, calls: [] as { readonly sinceMs: number; readonly untilMs: number }[], + credentials: [] as unknown[], }; - const read = (_credential: unknown, sinceMs: number, untilMs: number) => + const read = (credential: unknown, sinceMs: number, untilMs: number) => Effect.gen(function* () { state.calls.push({ sinceMs, untilMs }); + state.credentials.push(credential); if (state.gate !== undefined) yield* Deferred.await(state.gate); const { accountKey, error } = state; if (error !== null) return { accountKey, records: [], missing: false, error }; @@ -281,6 +296,10 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { return summary.sources.find((source) => source.fingerprint.provider === "cursor"); } +function cursorSources(summary: { readonly sources: readonly UsageSource[] }) { + return summary.sources.filter((source) => source.fingerprint.provider === "cursor"); +} + const seedCodexHistory = (input: { id: string; nativeTurnId?: string; @@ -1507,11 +1526,6 @@ describe("UsageService", () => { environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, authPath: ["config", "cursor", "auth.json"], }, - { - platform: "linux" as const, - environment: { CURSOR_API_KEY: "different-account" }, - authPath: ["config", "cursor", "auth.json"], - }, ].entries()) { const authPath = NodePath.join(home, ...testCase.authPath); yield* Effect.promise(async () => { @@ -1541,6 +1555,212 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("reads the Cursor account of an API key or T3 sign-in instead of a CLI login", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const signIn = new TextEncoder().encode( + encodeUnknownJsonString({ + version: 1, + backendUrl: "https://api2.cursor.sh", + apiKey: "signed-in-key", + createdAtMs: 0, + }), + ); + for (const [index, testCase] of [ + { environment: { CURSOR_API_KEY: "environment-key" }, stored: Option.none() }, + { environment: {}, stored: Option.some(signIn) }, + ].entries()) { + const cursor = makeFakeCursor(); + const summary = yield* Effect.gen(function* () { + const service = yield* makeWithCursor(cursor.read); + return yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + }).pipe( + Effect.provide( + layerService({ + prefix: `usage-service-cursor-api-key-${index}`, + home, + settings, + platform: "darwin", + environment: testCase.environment, + secretStore: { get: () => Effect.succeed(testCase.stored) }, + }), + ), + ); + assert.deepStrictEqual(cursor.state.credentials, [ + { kind: "apiKey", apiKey: index === 0 ? "environment-key" : "signed-in-key" }, + ]); + assert.strictEqual(cursorSource(summary)?.status, "ok"); + } + }).pipe(Effect.scoped), + ); + + it.live("does not show a previous Cursor account's history after the API key changes", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* TestClock.setTime(CURSOR_NOW); + let apiKey = "first-account-key"; + const signIn = () => + Option.some( + new TextEncoder().encode( + encodeUnknownJsonString({ + version: 1, + backendUrl: "https://api2.cursor.sh", + apiKey, + createdAtMs: 0, + }), + ), + ); + const cursor = makeFakeCursor(); + cursor.state.events = [{ timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }]; + yield* Effect.gen(function* () { + const service = yield* makeWithCursor(cursor.read); + const first = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.isTrue(first.buckets.some((bucket) => bucket.provider === "cursor")); + + apiKey = "second-account-key"; + cursor.state.error = "Cursor account usage could not be read."; + const second = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.isFalse(second.buckets.some((bucket) => bucket.provider === "cursor")); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-key-change", + home, + settings, + platform: "darwin", + environment: {}, + secretStore: { get: () => Effect.sync(signIn) }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.live("keeps a new Cursor key's refresh visible beside a failing key", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const cursor = makeFakeCursor(); + const started = yield* Deferred.make(); + const gate = yield* Deferred.make(); + yield* Effect.gen(function* () { + const context = yield* Layer.build( + layerCursorUsageAccounts( + Layer.succeed(CursorAccountReader.CursorAccountReader, { + read: (credential, sinceMs, untilMs) => + (credential as { apiKey?: string }).apiKey === "failing-key" + ? Effect.succeed({ + accountKey: null, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }) + : Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(gate)), + Effect.andThen(cursor.read(credential, sinceMs, untilMs)), + ), + }), + ), + ); + const accounts = Context.get(context, CursorUsageAccounts.CursorUsageAccounts); + const scan = (awaitRefresh: boolean) => + accounts.scan({ + instances: [ + { + instanceId: ProviderInstanceId.make("cursor"), + environment: { CURSOR_API_KEY: "failing-key" }, + }, + { + instanceId: ProviderInstanceId.make("cursor-work"), + environment: { CURSOR_API_KEY: "new-key" }, + }, + ], + keychainUsageEnabled: false, + windowStartMs: 0, + retentionCutoffMs: 0, + awaitRefresh, + }); + const first = yield* Effect.forkChild(scan(true)); + yield* Deferred.await(started); + const sources = yield* scan(false); + assert.isTrue(sources.some((source) => source.refreshing === true)); + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(first); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-new-key", + home, + settings, + platform: "linux", + environment: {}, + }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live("reports one Cursor source when two API keys belong to the same account", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* TestClock.setTime(CURSOR_NOW); + const cursor = makeFakeCursor(); + cursor.state.events = [{ timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }]; + let revoked = false; + yield* Effect.gen(function* () { + const service = yield* makeWithCursor((credential, sinceMs, untilMs) => + revoked && (credential as { apiKey?: string }).apiKey === "second-key" + ? Effect.succeed({ + accountKey: null, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }) + : cursor.read(credential, sinceMs, untilMs), + ); + const first = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.strictEqual(cursor.state.credentials.length, 2); + assert.deepStrictEqual( + cursorSources(first).map((source) => source.distinctSessions), + [1], + ); + + // The revoked key keeps its stale cache, which must not replace the working key's history. + revoked = true; + cursor.state.events.push({ timestampMs: CURSOR_NOW + 60 * 1000, outputTokens: 5 }); + yield* TestClock.adjust(Duration.minutes(2)); + const second = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.deepStrictEqual( + cursorSources(second).map((source) => source.status), + ["ok"], + ); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-shared-account", + home, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("cursor")]: { + driver: ProviderDriverKind.make("cursor"), + environment: [{ name: "CURSOR_API_KEY", value: "first-key", sensitive: true }], + }, + [ProviderInstanceId.make("cursor-work")]: { + driver: ProviderDriverKind.make("cursor"), + environment: [{ name: "CURSOR_API_KEY", value: "second-key", sensitive: true }], + }, + }, + }, + platform: "darwin", + environment: {}, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live( "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account", () => @@ -2077,6 +2297,111 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("filters original model IDs before aliases, pricing, buckets and session counts", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile( + transcript, + claudeLine(1, 3, "orbit-1", "mixed") + + claudeLine(2, 5, "orbit-2", "mixed") + + claudeLine(3, 7, "drift-9", "mixed") + + claudeLine(4, 11, "drift-9", "excluded-only"), + ), + ); + const rates = { + "orbit-1": { input_cost_per_token: 0.000001, output_cost_per_token: 0.00001 }, + "orbit-2": { input_cost_per_token: 0.000002, output_cost_per_token: 0.00002 }, + }; + const include = { + // The codex selector must not match the Claude drift-9 records. + include: [ + { provider: "claude", model: "orbit-1" }, + { model: "orbit-2" }, + { provider: "codex", model: "drift-9" }, + ], + }; + const exclude = { exclude: [{ model: "drift-9" }] }; + const HOURLY: UsageSummaryInput = { + ...WINDOW, + resolution: "hour", + sinceTime: "2026-08-01T09:00:00Z", + untilTime: "2026-08-01T11:00:00Z", + }; + const cells = (summary: UsageSummary) => + summary.buckets.map(({ model, totals, costUsd, records, sessions }) => ({ + model, + outputTokens: totals.outputTokens, + costUsd: Math.round(costUsd * 1e6), + records, + sessions, + })); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + // Same service, concurrent reads: the in-flight key must keep these scans apart. + const [unfiltered, noOp, keepNothing, included, reordered, excluded, hourly] = + yield* Effect.all( + [ + service.readSummary(WINDOW), + service.readSummary({ ...WINDOW, modelFilter: { exclude: [] } }), + service.readSummary({ ...WINDOW, modelFilter: { include: [] } }), + service.readSummary({ ...WINDOW, modelFilter: include }), + service.readSummary({ + ...WINDOW, + modelFilter: { include: [...include.include].reverse() }, + }), + service.readSummary({ ...WINDOW, modelFilter: exclude }), + service.readSummary({ ...HOURLY, modelFilter: include }), + ], + { concurrency: "unbounded" }, + ); + + // Unfiltered keeps today's behavior: the alias folds drift-9 into orbit-1. + assert.deepStrictEqual(cells(unfiltered), [ + { model: "orbit-1", outputTokens: 21, costUsd: 240, records: 3, sessions: 2 }, + { model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 }, + ]); + assert.strictEqual(unfiltered.sources[0]?.distinctSessions, 2); + assert.strictEqual(unfiltered.modelFilter, undefined); + assert.strictEqual(noOp, unfiltered); + assert.deepStrictEqual(keepNothing.buckets, []); + assert.strictEqual(keepNothing.sources[0]?.distinctSessions, 0); + + const kept = [ + { model: "orbit-1", outputTokens: 3, costUsd: 40, records: 1, sessions: 1 }, + { model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 }, + ]; + assert.deepStrictEqual(cells(included), kept); + assert.deepStrictEqual(cells(excluded), kept); + assert.deepStrictEqual(cells(hourly), kept); + assert.strictEqual(hourly.buckets[0]?.hourStart, "2026-08-01T10:00:00.000Z"); + for (const summary of [included, excluded, hourly]) { + assert.strictEqual(summary.sources[0]?.distinctSessions, 1); + } + // Equal filters share one scan and echo one canonical form. + assert.strictEqual(reordered, included); + assert.deepStrictEqual(included.modelFilter, { + include: [ + { provider: "claude", model: "orbit-1" }, + { provider: "codex", model: "drift-9" }, + { model: "orbit-2" }, + ], + }); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-service-model-filter-test", + home, + settings: { ...settings, usageModelAliases: { "drift-9": "orbit-1" } }, + ratesDocument: rates, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -2299,7 +2624,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.each([4, 5, 6, 7, 8])( + it.live.each([4, 5, 6, 7, 8, 10])( "upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts and the old file", (version) => Effect.gen(function* () { @@ -2366,7 +2691,7 @@ describe("UsageService", () => { delete file.cs.lastCumulativeUsage; delete file.cs.malformedRecords; } - if (version === 8 && path === livePath) { + if (version >= 8 && path === livePath) { file.r = []; file.mr = 1; file.cs.malformedRecords = 1; @@ -2398,7 +2723,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v10.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v11.json"); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, @@ -2425,6 +2750,106 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("re-parses a clean v10 Codex rollout that grew after an interrupted request", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + const live = NodePath.join(sessions, "live.jsonl"); + const usage = (input: number, output: number) => ({ + input_tokens: input, + output_tokens: output, + total_tokens: input + output, + }); + const lines = (values: readonly unknown[]) => + values.map((value) => `${encodeUnknownJsonString(value)}\n`).join(""); + const request = (second: number, last: unknown) => ({ + type: "token_usage_record", + timestamp: `2026-08-01T10:00:0${second}Z`, + payload: { usage: last }, + }); + const tokenCount = (second: number, last: unknown, total: unknown) => ({ + type: "event_msg", + timestamp: `2026-08-01T10:00:0${second}Z`, + payload: { + type: "token_count", + info: { last_token_usage: last, total_token_usage: total }, + }, + }); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + live, + lines([ + { type: "session_meta", payload: { id: "live" } }, + { type: "turn_context", payload: { turn_id: "turn-1", model: "gpt-6-astra" } }, + request(1, usage(100, 20)), + tokenCount(1, usage(100, 20), usage(100, 20)), + // Interrupted after this request finished: no token_count reports it. + request(2, usage(150, 10)), + ]), + ); + }); + + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const tokens = (summary: UsageSummary) => + summary.buckets.reduce((sum, bucket) => sum + totalTokens(bucket.totals), 0); + const first = yield* UsageService.make; + assert.strictEqual(tokens(yield* first.readSummary(WINDOW)), 120); + yield* first.awaitPersisted; + + yield* Effect.promise(async () => { + const cachePath = NodePath.join(stateDir, SCAN_CACHE_FILE_NAME); + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record }>; + }; + // The v10 parser ignored `token_usage_record`, so its clean state held nothing pending. + for (const file of Object.values(document.files)) { + file.cs = Object.fromEntries( + Object.entries(file.cs).filter(([key]) => !key.startsWith("unreported")), + ); + } + await NodeFSP.rm(cachePath); + await NodeFSP.writeFile( + NodePath.join(stateDir, "usage-scan-cache-v10.json"), + encodeUnknownJsonString({ ...document, version: 10 }), + ); + await NodeFSP.appendFile( + live, + lines([ + { type: "turn_context", payload: { turn_id: "turn-2", model: "gpt-6-astra" } }, + request(5, usage(200, 30)), + tokenCount(5, usage(200, 30), usage(450, 60)), + ]), + ); + }); + + const upgraded = yield* UsageService.make; + const summary = yield* upgraded.readSummary(WINDOW); + assert.deepStrictEqual( + { + tokens: tokens(summary), + malformedRecords: summary.sources.find( + (source) => source.fingerprint.provider === "codex", + )?.malformedRecords, + }, + { tokens: 510, malformedRecords: 0 }, + ); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cache-v10-interrupted-test", + home, + settings, + ratesDocument: { + "gpt-6-astra": { input_cost_per_token: 0, output_cost_per_token: 1 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f5f10fb156f3f..7e2e8586ed95f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -39,7 +39,7 @@ import type { TranscriptUsageFormat, UsageRecord, } from "@t3tools/provider-core/server/usage"; -import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; +import { canonicalModelFilter, resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -724,6 +724,7 @@ export const make = Effect.gen(function* () { rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), modelAliases: resolveModelAliases(settings.usageModelAliases), + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), }); const sources: UsageSource[] = []; @@ -882,6 +883,7 @@ export const make = Effect.gen(function* () { sources, pricing: pricing(), scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), } satisfies UsageSummary; }); @@ -897,9 +899,15 @@ export const make = Effect.gen(function* () { input.untilTime ?? null, settings, input.awaitRefresh === true, + input.modelFilter ?? null, ]); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const readSummary = Effect.fn("UsageService.readSummary")(function* ( + requested: UsageSummaryInput, + ) { + const { modelFilter: requestedFilter, ...unfiltered } = requested; + const modelFilter = canonicalModelFilter(requestedFilter); + const input = modelFilter === undefined ? unfiltered : { ...unfiltered, modelFilter }; const settings = yield* readSettings; const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index eb956a67a31d0..daa1ea909f034 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -6,7 +6,7 @@ import { type UsageLimitSourceConfig, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -156,12 +156,12 @@ const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(fun accountId: string, creditId: string, ) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const name = new TextEncoder().encode(`${accountId}:${creditId}`); const input = new Uint8Array(CREDIT_REDEEM_NAMESPACE.length + name.length); input.set(CREDIT_REDEEM_NAMESPACE); input.set(name, CREDIT_REDEEM_NAMESPACE.length); - const bytes = (yield* crypto.digest("SHA-1", input).pipe(Effect.orDie)).slice(0, 16); + const bytes = (yield* digest.digest(Digest.sha1, input).pipe(Effect.orDie)).slice(0, 16); bytes[6] = (bytes[6]! & 0x0f) | 0x50; bytes[8] = (bytes[8]! & 0x3f) | 0x80; const hex = Hex.encode(bytes); @@ -170,7 +170,7 @@ const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(fun export const makeCliproxyApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const management = Effect.fn("CliproxyApi.management")(function* ( config: UsageLimitSourceConfig, @@ -401,7 +401,7 @@ export const makeCliproxyApi = Effect.gen(function* () { redeem_request_id: yield* creditRedeemRequestId( account.id_token?.chatgpt_account_id ?? account.id, creditId, - ).pipe(Effect.provideService(Crypto.Crypto, crypto)), + ).pipe(Effect.provideService(Digest.Digest, digest)), credit_id: creditId, }); const response = yield* decodeConsumeResponse(body); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 17903cd062eae..8f94c84a3c470 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -3,6 +3,8 @@ import type { UsageBucket, UsageCategoryCost, UsageDay, + UsageModelFilter, + UsageModelSelector, UsageResolution, UsageTokenTotals, } from "@t3tools/contracts"; @@ -67,6 +69,7 @@ export interface AggregateOptions { readonly rates: RateTable; readonly priceOverrides?: RateTable; readonly modelAliases?: ReadonlyMap; + readonly modelFilter?: UsageModelFilter; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; @@ -88,6 +91,38 @@ export function resolveModelAliases( return resolved; } +/** Matches original IDs, so an alias cannot pull excluded usage into a kept model. */ +function keepsUsageRecord(filter: UsageModelFilter | undefined, record: UsageRecord): boolean { + if (filter === undefined) return true; + const model = record.model.trim(); + const matches = (selector: UsageModelSelector) => + selector.model === model && + (selector.provider === undefined || selector.provider === record.provider); + return (filter.include?.some(matches) ?? true) && !(filter.exclude?.some(matches) ?? false); +} + +const selectorKey = (selector: UsageModelSelector) => + JSON.stringify([selector.provider ?? null, selector.model]); + +const canonicalSelectors = (selectors: readonly UsageModelSelector[]) => + [...new Map(selectors.map((selector) => [selectorKey(selector), selector]))] + // Code-unit order, so servers in different locales echo the same form. + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([, selector]) => selector); + +/** Equal filters get one form, so they share scans and clients can compare echoed filters. */ +export function canonicalModelFilter( + filter: UsageModelFilter | undefined, +): UsageModelFilter | undefined { + if (filter === undefined) return undefined; + const exclude = filter.exclude?.length ? canonicalSelectors(filter.exclude) : undefined; + if (filter.include === undefined && exclude === undefined) return undefined; + return { + ...(filter.include === undefined ? {} : { include: canonicalSelectors(filter.include) }), + ...(exclude === undefined ? {} : { exclude }), + }; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; readonly duplicatesDropped: number; @@ -129,6 +164,7 @@ export class UsageAggregator { } add(input: UsageRecord, sourcePath?: string): boolean { + if (!keepsUsageRecord(this.#options.modelFilter, input)) return false; const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 2ce6d6f74eff7..b22625b7e490c 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -258,4 +258,32 @@ describe("usage pricing", () => { expect(lookupRate(table, "provider-b/example-model")?.inputCostPerToken).toBe(3); expect(lookupRate(table, "example-model")).toBeNull(); }); + + it("prices a bare name at the model maker's rate when a reseller marks it up", () => { + const reseller = { "kyma/gemini-4-argon": rate(5.4e-6) }; + const makers = { + "gemini/gemini-4-argon": rate(2e-6, 1e-7), + "vertex_ai/gemini-4-argon": rate(2e-6, 1e-7), + }; + + for (const document of [ + { ...reseller, ...makers }, + { ...makers, ...reseller }, + ]) { + const table = parseRateTable(document); + expect(lookupRate(table, "gemini-4-argon")).toEqual( + lookupRate(table, "gemini/gemini-4-argon"), + ); + } + }); + + it("leaves a bare name unpriced when model makers' own entries disagree", () => { + const table = parseRateTable({ + "openai/example-model": rate(1), + "xai/example-model": rate(3), + "kyma/example-model": rate(1), + }); + + expect(lookupRate(table, "example-model")).toBeNull(); + }); }); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index fa512218d7c0c..1f515abb736c8 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -89,6 +89,17 @@ function fastMultiplier(entry: LiteLlmEntry): number | null { return fast !== null && fast > 0 ? fast : null; } +// LiteLLM prefixes for the model makers' own APIs; their price beats a cloud's or reseller's for a bare name. +const FIRST_PARTY_PROVIDERS = new Set([ + "anthropic", + "deepseek", + "gemini", + "meta", + "mistral", + "openai", + "xai", +]); + /** Drops models missing input or output rates to avoid understating cost. */ export function parseRateTable(document: unknown): RateTable { const table = new Map(); @@ -113,18 +124,19 @@ export function parseRateTable(document: unknown): RateTable { }); } - const aliasCandidates = new Map(); + const aliasCandidates = new Map(); for (const [key, rate] of table) { const alias = bareModelName(key); if (alias.length === 0 || alias === key || table.has(alias)) continue; + const firstParty = FIRST_PARTY_PROVIDERS.has(key.slice(0, key.indexOf("/"))); const held = aliasCandidates.get(alias); - if (held === undefined) { - aliasCandidates.set(alias, rate); - } else if (held !== null) { - aliasCandidates.set(alias, mergeAliasRate(held, rate)); + if (held === undefined || (firstParty && !held.firstParty)) { + aliasCandidates.set(alias, { rate, firstParty }); + } else if (held.rate !== null && firstParty === held.firstParty) { + aliasCandidates.set(alias, { rate: mergeAliasRate(held.rate, rate), firstParty }); } } - for (const [alias, rate] of aliasCandidates) { + for (const [alias, { rate }] of aliasCandidates) { if (rate !== null) table.set(alias, rate); } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 477b8bb6fc4f6..fa659c4a443b3 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -161,7 +161,7 @@ describe("scan cache round trip", () => { expect(restored).toEqual(original); }); - it.each([5, 6, 7, 8])( + it.each([5, 6, 7, 8, 10])( "retains v%s history and compatible positions but invalidates obsolete state", (version) => { const original = cacheWith([["/gone.jsonl", 100, [record({ speed: "fast" })]]]); @@ -218,25 +218,25 @@ describe("scan cache round trip", () => { }; const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP); - expect(restored.get("/gone.jsonl")).toEqual({ - ...original.get("/gone.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }); - expect(restored.get("/gone-codex.jsonl")).toEqual({ - ...original.get("/gone-codex.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }); - expect(restored.get("/clean-codex.jsonl")).toEqual( - version >= 8 - ? cleanCodex + expect(restored.get("/gone.jsonl")).toEqual( + version >= 10 + ? original.get("/gone.jsonl") : { - ...cleanCodex, + ...original.get("/gone.jsonl"), size: -1, position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, }, ); + expect(restored.get("/gone-codex.jsonl")).toEqual({ + ...original.get("/gone-codex.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); + expect(restored.get("/clean-codex.jsonl")).toEqual({ + ...cleanCodex, + size: version >= 8 ? cleanCodex.size : -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); expect(restored.get("/gone-pi.jsonl")).toEqual( version >= 6 ? original.get("/gone-pi.jsonl") diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 70c0c3df17e3e..c9032d64c05bd 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 10 as const; +const USAGE_SCAN_CACHE_VERSION = 11 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -17,8 +17,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * both. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v10.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v11.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v10.json", "usage-scan-cache-v9.json", "usage-scan-cache-v8.json", "usage-scan-cache-v7.json", @@ -291,16 +292,19 @@ export function decodeScanCache( continue; } // Keep history for deleted rollouts, but re-parse incompatible reducer state. - // v9 repairs skipped initial Codex requests; clean v8 entries can still resume. + // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged. // v10 adds Claude advisor calls, which no older Claude entry holds. const legacy = (entry.p === "claude" && version < 10) || (format.state !== undefined && (version < 6 || - (entry.p === "codex" && (version < 8 || (version < 9 && malformedRecords > 0))))); + (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0))))); // 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 v11 a clean scan could end past an interrupted request whose usage its state dropped, + // so a grown file re-parses whole instead of resuming. + const resumable = !legacy && !(entry.p === "codex" && version < 11); const provider = entry.p; const records = decodeRecords(entry.r, provider); @@ -314,14 +318,14 @@ export function decodeScanCache( records, tailRecords, malformedRecords, - position: legacy - ? { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null } - : { + position: resumable + ? { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, state: entry.cs, - }, + } + : { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, }); } diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index f2b4fb43b48b4..105884d81ccfb 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -7,7 +7,7 @@ import * as NodePath from "node:path"; import * as NodeZlib from "node:zlib"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; -import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; +import { totalTokens, type TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; import * as Schema from "effect/Schema"; import { TEST_FORMATS } from "./usageTestFormats.ts"; @@ -381,6 +381,82 @@ describe("readTranscriptRecords resume", () => { }, ); + it.each([ + { mode: "buffered", streamingThresholdBytes: Infinity }, + { mode: "streaming", streamingThresholdBytes: 0 }, + ])( + "bills a Codex request whose turn was interrupted before its token_count ($mode)", + async (options) => { + const path = NodePath.join(dir, "rollout.jsonl"); + const usage = (input: number, output: number) => ({ + input_tokens: input, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: 0, + total_tokens: input + output, + }); + const context = (turn: number) => ({ + type: "turn_context", + timestamp: `2026-11-02T10:00:0${turn * 4 - 4}.000Z`, + payload: { turn_id: `turn-${turn}`, model: "gpt-6-sol" }, + }); + const request = (turn: number, id: number, timestamp: string, last: unknown) => ({ + type: "token_usage_record", + timestamp, + payload: { turn_id: `turn-${turn}`, response_id: `resp_${id}`, usage: last }, + }); + const tokenCount = (timestamp: string, last: unknown, total: unknown) => ({ + type: "event_msg", + timestamp, + payload: { + type: "token_count", + info: { last_token_usage: last, total_token_usage: total }, + }, + }); + const lines = [ + { + type: "session_meta", + timestamp: "2026-11-02T10:00:00.000Z", + payload: { id: "aborted-turn-repro", model_provider: "openai" }, + }, + context(1), + request(1, 1, "2026-11-02T10:00:01.000Z", usage(100, 20)), + tokenCount("2026-11-02T10:00:01.001Z", usage(100, 20), usage(100, 20)), + // Interrupted after this request finished: no token_count reports it. + request(1, 2, "2026-11-02T10:00:02.000Z", usage(150, 10)), + { + type: "event_msg", + timestamp: "2026-11-02T10:00:03.000Z", + payload: { type: "turn_aborted", turn_id: "turn-1", reason: "interrupted" }, + }, + context(2), + request(2, 3, "2026-11-02T10:00:05.000Z", usage(200, 30)), + tokenCount("2026-11-02T10:00:05.001Z", usage(200, 30), usage(450, 60)), + ]; + // Lines longer than a read chunk take the projected path when streaming. + const padding = "x".repeat(256 * 1024); + await NodeFSP.writeFile( + path, + lines.map((line) => `${JSON.stringify({ padding, ...line })}\n`).join(""), + ); + + const result = await readTranscriptRecords(path, TEST_FORMATS.codex, undefined, options); + assert.isNotNull(result); + const requests = [...result.records, ...result.tailRecords] + .map((record) => totalTokens(record.totals)) + .toSorted((a, b) => a - b); + assert.deepStrictEqual( + { + billedTokens: requests.reduce((sum, tokens) => sum + tokens, 0), + requests, + malformedRecords: result.malformedRecords, + }, + { billedTokens: 510, requests: [120, 160, 230], malformedRecords: 0 }, + ); + }, + ); + it("re-parses from the start when the guard bytes no longer match", async () => { const path = NodePath.join(dir, "claude.jsonl"); await NodeFSP.writeFile(path, claudeLine(1, 5)); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index ba2b4abe50af7..9262688fd10b5 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -35,11 +36,11 @@ const layerServerConfig = ServerConfig.layerTest(process.cwd(), { const layerGitContract = Layer.mergeAll(GitVcsDriver.layerVcs, GitVcsDriver.layer).pipe( Layer.provide(layerServerConfig), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerGitCaptureContract = Layer.merge( layerGitContract, - ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), + ProcessRunner.layer.pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const runGit = (cwd: string, args: ReadonlyArray) => @@ -596,7 +597,17 @@ it.effect.each( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const liveProcess = yield* VcsProcess.VcsProcess; + let freshReadTrees = 0; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + if (input.args.includes("read-tree") && input.args.includes("index.sparse=true")) + freshReadTrees++; + return liveProcess.run(input); + }, + }), + ); const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); const { git } = yield* makeCheckpointFixture(driver, cwd); const write = Effect.fn(function* (name: string, contents: string) { @@ -679,11 +690,12 @@ it.effect.each( "working outside\n", ); } + assert.strictEqual(freshReadTrees, indexState === "missing" ? 2 : 0); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); it.effect.each( - (["normal", "flags", "sparse"] as const).flatMap((indexMode) => + (["normal", "skip-worktree", "assume-unchanged", "sparse"] as const).flatMap((indexMode) => [1, 3, 4096].map((chunkSize) => ({ indexMode, chunkSize })), ), )( @@ -699,6 +711,7 @@ it.effect.each( yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); + yield* fs.writeFileString(path.join(cwd, "z-deleted"), "original\n"); yield* fs.makeDirectory(path.join(cwd, "excluded")); yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); yield* fs.writeFileString( @@ -706,19 +719,31 @@ it.effect.each( 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', ); yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* git(["config", "core.trustctime", "false"]); yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); + yield* fs.utimes(path.join(cwd, "z-skipped"), 1_700_000_000, 1_700_000_000); yield* git(["add", "."]); yield* git(["commit", "-m", "inspection fixture"]); - if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); + const flagged = indexMode === "skip-worktree" || indexMode === "assume-unchanged"; + if (flagged) { + yield* git(["update-index", `--${indexMode}`, "z-skipped", "z-deleted"]); + yield* git(["config", "core.ignorestat", "true"]); + yield* fs.remove(path.join(cwd, "z-deleted")); + } if (indexMode === "sparse") yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); + // A flagged edit with the indexed size and mtime is invisible to Git's stat check. + if (flagged) yield* fs.utimes(path.join(cwd, "z-skipped"), 1_700_000_000, 1_700_000_000); yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + let freshReadTrees = 0; const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - liveProcess.run( + run: (input) => { + if (input.args.includes("read-tree") && !input.args.includes("--reset")) + freshReadTrees++; + return liveProcess.run( input.args.includes("ls-files") ? { ...input, @@ -729,13 +754,19 @@ it.effect.each( }, } : input, - ), + ); + }, }), ); yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n"); - if (indexMode !== "flags") - assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); + const files = (yield* git(["ls-tree", "-z", "--name-only", checkpointRef])).stdout.split( + "\0", + ); + if (flagged) assert.notInclude(files, "z-deleted"); + else assert.include(files, "z-deleted"); + assert.strictEqual(freshReadTrees, 0); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); @@ -1110,7 +1141,10 @@ const vcsProcessSpawning = (command: string) => Effect.map(VcsProcess.make, (vcsProcess) => VcsProcess.VcsProcess.of({ run: (input) => vcsProcess.run({ ...input, command }) }), ), - ).pipe(Layer.provide(ProcessRunner.layer), Layer.provideMerge(NodeServices.layer)); + ).pipe( + Layer.provide(ProcessRunner.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); it.effect("GitVcsDriver treats a missing git executable as outside any repository", () => Effect.gen(function* () { @@ -1123,7 +1157,7 @@ it.effect("GitVcsDriver treats a missing git executable as outside any repositor assert.strictEqual(yield* driver.isInsideWorkTree(cwd), false); assert.strictEqual(yield* driver.detectRepository(cwd), null); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( @@ -1159,7 +1193,7 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( assert.strictEqual(error._tag, "VcsProcessSpawnError"); const detectionError = yield* driver.detectRepository(process.cwd()).pipe(Effect.flip); assert.strictEqual(detectionError._tag, "VcsProcessSpawnError"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const gitCommandCount = (operation: string, outcome: string) => @@ -1212,7 +1246,7 @@ it.effect("GitVcsDriver meters each git command once under its operation", () => assert.strictEqual(timing.count, 2); assert.strictEqual(timing.sum, 500); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); @@ -1236,7 +1270,7 @@ it.effect("GitVcsDriver counts an allowed non-zero exit as a success", () => assert.strictEqual(timing.count, 1); assert.strictEqual(timing.sum, 250); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); @@ -1257,7 +1291,7 @@ it.effect("GitVcsDriver counts a failed git command as a failure", () => assert.strictEqual(timing.count, 1); assert.strictEqual(timing.sum, 250); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); @@ -1322,7 +1356,7 @@ it.effect("git metrics finalize cancellation without exposing arbitrary operatio assert.strictEqual(timing.count, 1); assert.strictEqual(timing.sum, 250); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); @@ -1378,7 +1412,7 @@ it.effect("git metrics count fsync fallback and transient retry subprocesses sep assert.strictEqual(timing.count, 3); assert.strictEqual(timing.sum, 750); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index b6f5fbbf496a9..15392cb96ce5e 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,7 +1,7 @@ import * as NodeBuffer from "node:buffer"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -444,6 +444,7 @@ export class GitVcsDriver extends Context.Service< >()("t3/vcs/GitVcsDriver") {} const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +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; @@ -566,7 +567,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const vcsProcess = yield* VcsProcess.VcsProcess; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const hostPlatform = yield* HostProcess.Platform; const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => gitCommand( @@ -910,7 +911,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "splitIndex.sharedIndexExpire=never", ]; const { gitCommonDir, indexPath, headExists } = yield* resolveCaptureRepository(input.cwd); - const indexId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const indexId = yield* random.uuidV4(); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const commitEnv: NodeJS.ProcessEnv = { ...process.env, @@ -967,43 +968,60 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); // read-tree can rewrite the index, so restore its racy timestamp afterward. yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); - let specialFlags = false; + // Manually flagged entries hide edits from add -A, so they are re-added below + // with no stat cache. Every other entry keeps its stat data and is not rehashed. + let fallback = false; + let flaggedBytes = 0; let recordStart = true; - let skipped = false; - let skippedRecord: Uint8Array[] = []; - const skippedPaths: string[] = []; + let collect: "flagged" | "skipped" | undefined; + let record: Uint8Array[] = []; + const flagged: string[] = []; + const skipped: string[] = []; yield* vcsProcess.run({ operation, command: "git", cwd: input.cwd, - args: [...indexConfig, "ls-files", "--full-name", "--sparse", "-v", "-z"], + args: [...indexConfig, "ls-files", "--full-name", "--sparse", "--stage", "-v", "-z"], env: commitEnv, maxOutputBytes: 4_096, outputMode: "truncate", - // Only tags and sparse skip-worktree paths matter; scan NUL-delimited records. + // Only tags and flagged or skip-worktree records matter; scan NUL-delimited records. onStdoutChunk: (chunk) => { let start = 0; - while (start < chunk.length) { + while (start < chunk.length && !fallback) { const end = chunk.indexOf(0, start); if (recordStart) { const tag = chunk[start]!; - skipped = tag === 83; - if ((tag >= 97 && tag <= 122) || (!sparseCheckout && tag === 83)) { - specialFlags = true; - } + record = []; + // Sparse skip-worktree bits are expected; only in-cone ones are manual. + collect = + tag === 83 && sparseCheckout + ? "skipped" + : (tag >= 97 && tag <= 122) || tag === 83 + ? "flagged" + : undefined; + // index-info cannot keep skip-worktree while dropping assume-unchanged. + if (tag === 115 && sparseCheckout) fallback = true; } - if (skipped && sparseCheckout) { - skippedRecord.push( - Buffer.from(chunk.subarray(start, end === -1 ? undefined : end)), - ); - if (end !== -1) { - const record = Buffer.concat(skippedRecord); - if (record.at(-1) !== 47) { - const name = record.subarray(2); - if (!NodeBuffer.isUtf8(name)) specialFlags = true; - else skippedPaths.push(name.toString("utf8")); + if (collect !== undefined && !fallback) { + const piece = chunk.subarray(start, end === -1 ? undefined : end); + record.push(Buffer.from(piece)); + if ( + collect === "flagged" && + (flaggedBytes += piece.length) > FLAGGED_INDEX_ENTRIES_MAX_BYTES + ) + fallback = true; + if (end !== -1 && !fallback) { + const full = Buffer.concat(record); + record = []; + // Sparse directory entries stay collapsed. + if (collect === "flagged" || full.at(-1) !== 47) { + // Drop the " " prefix, leaving index-info's " \t". + const entry = full.subarray(2); + if (!NodeBuffer.isUtf8(entry)) fallback = true; + else + (collect === "flagged" ? flagged : skipped).push(entry.toString("utf8")); } - skippedRecord = []; } } recordStart = end !== -1; @@ -1012,20 +1030,46 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } }, }); - if (skippedPaths.length > 0 && !specialFlags) { - const selected = yield* execute({ + if (fallback) return false; + if (skipped.length > 0) { + const paths = skipped.map((entry) => entry.slice(entry.indexOf("\t") + 1)); + const stdin = paths.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: [...indexConfig, "sparse-checkout", "check-rules", "-z"], - stdin: skippedPaths.join("\0") + "\0", + stdin, env: commitEnv, - maxOutputBytes: 1, - outputMode: "truncate", + maxOutputBytes: Buffer.byteLength(stdin), + outputMode: "error", + }); + const selected = new Set(rules.stdout.split("\0")); + skipped.forEach((entry, index) => { + if (selected.has(paths[index]!)) flagged.push(entry); }); - specialFlags = selected.stdout.length > 0 || selected.stdoutTruncated; } - // Sparse Git clears skip-worktree for present files. Manual flags still need a reset. - return !specialFlags; + if (flagged.length > 0) { + // Flagged gitlinks keep the fresh-index behaviour rather than a guessed one. + if (flagged.some((entry) => entry.startsWith("160000 "))) return false; + yield* execute({ + operation, + cwd: input.cwd, + // core.ignorestat would mark these assume-unchanged, hiding edits from add -A. + args: [ + ...indexConfig, + "-c", + "core.ignorestat=false", + "update-index", + "-z", + "--index-info", + ], + stdin: flagged.join("\0") + "\0", + env: commitEnv, + }); + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + } + return true; }).pipe(Effect.orElseSucceed(() => false)); if (!reusedIndex) { if (sparseCheckout) { @@ -1377,6 +1421,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( // Not --no-relative, which Git before 2.28 rejects. "-c", "diff.relative=false", + "-c", + "diff.suppressBlankEmpty=false", "diff", ...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]), "--no-color", @@ -1436,6 +1482,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( args: [ "-c", "diff.relative=false", + // log.showSignature prints verification text into the path list. + "-c", + "log.showSignature=false", ...args, "--name-only", "-z", diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 2fa44600578a2..55d409a5e8bec 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2,6 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodeURL from "node:url"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; @@ -21,7 +22,7 @@ import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner, CommandLine } from "effect/process"; import { GitCommandError, @@ -52,7 +53,7 @@ const layerServerConfig = ServerConfig.layerTest(process.cwd(), { }); const layerTest = GitVcsDriver.layer.pipe( Layer.provideMerge(layerServerConfig), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const makeNonRepositoryHandle = () => @@ -104,7 +105,8 @@ const makeDriverReleasingLock = ( const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { const handle = yield* delegate.spawn(command); - if (!ChildProcess.isStandardCommand(command) || !matches(command.args)) return handle; + if (!ChildProcess.isStandardCommand(command) || !matches(command.args.map(String))) + return handle; return ChildProcessSpawner.makeHandle({ ...handle, exitCode: handle.exitCode.pipe( @@ -176,7 +178,13 @@ describe("Git output collection", () => { assert.deepEqual(withCallbacks, expected); assert.deepEqual(stdoutLines, ["a\\b\0雪", "last "]); assert.deepEqual(stderrLines, ["Receiving é", "Updating 雪", "done"]); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("caps output without line callbacks", () => @@ -196,7 +204,13 @@ describe("Git output collection", () => { const error = yield* driver.execute(input).pipe(Effect.flip); assert.equal(error.outputLength, 4); assert.include(error.detail, "exceeded 3 bytes"); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); }); @@ -354,7 +368,7 @@ it.effect.each([ if (!ChildProcess.isStandardCommand(command)) { return yield* Effect.die("expected a standard Git command"); } - spawnedArgs.push(command.args); + spawnedArgs.push(command.args.map(String)); if (invalidationFailure !== "none" && command.args.includes("--git-common-dir")) { if (invalidationFailure === "non-repository") return makeNonRepositoryHandle(); return ChildProcessSpawner.makeHandle({ @@ -447,7 +461,7 @@ it.effect.each(["branch", "detached", "nested", "aliased-root", "aliased-nested" const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawnedArgs: Array> = []; const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args.map(String)); return delegate.spawn(command); }); const driver = yield* makeGitVcsDriverCore().pipe( @@ -514,7 +528,7 @@ it.effect.each(["bare", "unborn"] as const)( const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawnedArgs: Array> = []; const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args.map(String)); return delegate.spawn( state === "bare" ? ChildProcess.setEnv(command, { GIT_DIR: cwd }) : command, ); @@ -552,6 +566,119 @@ it.effect.each(["bare", "unborn"] as const)( ).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) => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args.map(String)); + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const discoveries = () => { + const commands = spawnedArgs.filter( + (args) => + args.includes("--show-toplevel") || + args.includes("--git-common-dir") || + (args.includes("symbolic-ref") && args.includes("HEAD")), + ); + spawnedArgs.length = 0; + return commands.length; + }; + const cwd = yield* makeTmpDir(); + if (state === "unborn") { + yield* git(cwd, ["init", "-b", "main"]); + } else { + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "committed.txt", "committed\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add file"]); + if (state === "detached") yield* git(cwd, ["checkout", "--detach"]); + } + yield* writeTextFile(cwd, "new.txt", "new\n"); + spawnedArgs.length = 0; + + const workingTree = yield* driver.getReviewDiffPreview({ + cwd, + file: { path: "new.txt", previousPath: null, sourceKind: "working-tree" }, + }); + assert.equal(discoveries(), 1); + assert.deepStrictEqual( + workingTree.sources + .find((source) => source.kind === "working-tree")! + .files!.map((file) => file.path), + ["new.txt"], + ); + + if (state !== "unborn") { + const commitSha = yield* git(cwd, ["rev-parse", "HEAD"]); + spawnedArgs.length = 0; + const commit = yield* driver.getReviewDiffPreview({ + cwd, + commitSha, + file: { path: "committed.txt", previousPath: null, sourceKind: "commit" }, + }); + assert.equal(discoveries(), 1); + assert.include(commit.sources[0]!.diff, "+committed"); + } + + const general = yield* driver.getReviewDiffPreview({ cwd }); + assert.equal(discoveries(), 3); + assert.equal( + general.sources.find((source) => source.kind === "branch-range")!.headRef, + state === "detached" ? "HEAD" : yield* git(cwd, ["branch", "--show-current"]), + ); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.each([ + { kind: "working-tree", request: { sourceKind: "working-tree" } }, + { kind: "commit", request: { sourceKind: "commit", commitSha: "HEAD" } }, +] as const)("keeps $kind review requests safe without a worktree root", ({ request }) => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const bareRoot = yield* makeTmpDir("git-review-bare-"); + const spawner = ChildProcessSpawner.make((command) => + delegate.spawn( + ChildProcess.isStandardCommand(command) && command.options.cwd === bareRoot + ? ChildProcess.setEnv(command, { GIT_DIR: bareRoot }) + : command, + ), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const path = yield* Path.Path; + const file = { path: "README.md", previousPath: null, sourceKind: request.sourceKind }; + const input = (cwd: string) => ({ + cwd, + file, + ...("commitSha" in request ? { commitSha: request.commitSha } : {}), + }); + const source = yield* makeTmpDir(); + yield* initRepoWithCommit(source); + yield* git(source, ["clone", "--bare", source, bareRoot]); + const nonRepository = yield* makeTmpDir(); + for (const cwd of [bareRoot, nonRepository, path.join(nonRepository, "missing")]) { + assert.isEmpty((yield* driver.getReviewDiffPreview(input(cwd))).sources); + } + + const brokenConfig = yield* makeTmpDir(); + yield* initRepoWithCommit(brokenConfig); + yield* writeTextFile(brokenConfig, ".git/config", "[broken"); + const error = yield* driver.getReviewDiffPreview(input(brokenConfig)).pipe(Effect.flip); + assert.equal(error.operation, "GitVcsDriver.resolveRepositoryPaths.commonDir"); + }), + ).pipe(Effect.provide(layerTest)), +); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "preserves embedded newlines in repository paths after worktree creation", () => @@ -760,7 +887,7 @@ describe("pullCurrentBranch", () => { ChildProcess.isStandardCommand(command) && command.args.includes("pull") && command.args.includes("--ff-only"); - if (isPull) onPull?.(command.args); + if (isPull) onPull?.(command.args.map(String)); return isPull ? ChildProcessSpawner.makeHandle({ ...handle, @@ -921,7 +1048,13 @@ describe("pullCurrentBranch", () => { assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteHead); }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("still fails a pull that Git rejects, whatever its output size", () => @@ -943,7 +1076,13 @@ describe("pullCurrentBranch", () => { assert.lengthOf(pulls, 1); }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); }); @@ -1003,7 +1142,13 @@ it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => assert.isTrue(refs.refs.some((ref) => ref.name === "feature/during-refresh")); assert.equal(yield* Ref.get(refScans), 2); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("invalidates a ref snapshot when a mutation fails after changing Git", () => @@ -1035,7 +1180,13 @@ it.effect("invalidates a ref snapshot when a mutation fails after changing Git", const refs = yield* driver.listRefs({ cwd }); assert.isTrue(refs.refs.some((ref) => ref.name === "feature/partial-failure")); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("marks the current branch when worktree metadata is unavailable", () => @@ -1070,7 +1221,13 @@ it.effect("marks the current branch when worktree metadata is unavailable", () = assert.isTrue(refs.isRepo); assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("ignores worktree metadata for directories that no longer exist", () => @@ -1106,7 +1263,13 @@ it.effect("ignores worktree metadata for directories that no longer exist", () = assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("reads worktrees from git that rejects worktree list -z", () => @@ -1157,7 +1320,13 @@ it.effect("reads worktrees from git that rejects worktree list -z", () => worktreePath, ); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("does not report a separate git dir as the worktree of its branch", () => @@ -1270,7 +1439,7 @@ it.effect.each([ return yield* Effect.die("expected Git command"); if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); assert.deepEqual(command.args, ["fetch", "--quiet", "--", "origin"]); - assert.equal(command.options.env?.LC_ALL, "C"); + assert.equal(command.options.env?.LANGUAGE, "C"); assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); yield* Ref.update(attempts, (count) => count + 1); return ChildProcessSpawner.makeHandle({ @@ -1302,7 +1471,13 @@ it.effect.each([ assert.notProperty(error, "stderr"); assert.notProperty(error, "args"); assert.equal(yield* Ref.get(attempts), "attempts" in scenario ? scenario.attempts : 1); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("retries a fetch that lost a ref update to another Git process", () => @@ -1332,7 +1507,13 @@ it.effect("retries a fetch that lost a ref update to another Git process", () => const cwd = yield* makeTmpDir(); yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "main" }); assert.equal(yield* Ref.get(attempts), 2); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("lets a worktree checkout that is still working run past five minutes", () => @@ -1380,7 +1561,13 @@ it.effect("lets a worktree checkout that is still working run past five minutes" const result = yield* Fiber.join(creation); assert.equal(result.worktree.path, worktreePath); assert.equal(result.worktree.refName, "t3/slow-checkout"); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("cancels a slow submodule checkout without reporting best-effort success", () => @@ -1435,7 +1622,13 @@ it.effect("cancels a slow submodule checkout without reporting best-effort succe assert.isTrue(yield* Deferred.isDone(updateStopped)); assert.equal(yield* Ref.get(claimed), worktreePath); assert.isEmpty(yield* Ref.get(finished)); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); describe("rejected git refs", () => { @@ -1455,7 +1648,7 @@ describe("rejected git refs", () => { Layer.provide(layerServerConfig), Layer.provideMerge( Layer.merge( - NodeServices.layer, + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ), @@ -1661,7 +1854,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* initRepoWithCommit(cwd); const path = yield* Path.Path; const refName = "private/deleted-base"; - const locales: Array = []; + const locales: Array = []; const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawner = ChildProcessSpawner.make((command) => { if ( @@ -1670,7 +1863,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { command.args[0] === "branch" || command.args[0] === "checkout") ) { - locales.push(command.options.env?.LC_ALL); + locales.push(command.options.env?.LANGUAGE); } return delegate.spawn(command); }); @@ -2070,9 +2263,14 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("loads repository-relative files from a nested project directory", () => + it.effect("loads repository-relative files from nested directories and aliases", () => Effect.gen(function* () { - const cwd = yield* makeTmpDir(); + const directory = yield* makeTmpDir(); + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const platform = yield* HostProcess.Platform; + const cwd = path.join(directory, platform === "win32" ? "repository" : "repository\n "); + yield* fileSystem.makeDirectory(cwd); const { initialBranch } = yield* initRepoWithCommit(cwd); yield* git(cwd, ["checkout", "-b", "feature/nested"]); yield* writeTextFile(cwd, "nested/tracked.txt", "committed\n"); @@ -2080,27 +2278,34 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["commit", "-m", "nested file"]); yield* writeTextFile(cwd, "nested/tracked.txt", "changed\n"); yield* writeTextFile(cwd, "untracked.txt", "new\n"); - const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; const nestedCwd = path.join(cwd, "nested"); - const preview = yield* driver.getReviewDiffPreview({ - cwd: nestedCwd, - baseRef: initialBranch, - }); - assert.equal( - preview.sources.find((source) => source.kind === "working-tree")!.files!.length, - 2, - ); - for (const source of preview.sources) { - for (const file of source.files ?? []) { - const scoped = yield* driver.getReviewDiffPreview({ - cwd: nestedCwd, - baseRef: initialBranch, - file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind }, - }); - const patch = scoped.sources.find((item) => item.kind === source.kind)!; - assert.deepStrictEqual(patch.files, [file]); - assert.include(patch.diff, `b/${file.path}`); + const projectDirectories = [nestedCwd]; + if (symlinksSupported) { + const alias = path.join(directory, "alias"); + yield* fileSystem.symlink(nestedCwd, alias); + projectDirectories.push(alias); + } + for (const projectDirectory of projectDirectories) { + const preview = yield* driver.getReviewDiffPreview({ + cwd: projectDirectory, + baseRef: initialBranch, + }); + assert.equal( + preview.sources.find((source) => source.kind === "working-tree")!.files!.length, + 2, + ); + for (const source of preview.sources) { + for (const file of source.files ?? []) { + const scoped = yield* driver.getReviewDiffPreview({ + cwd: projectDirectory, + baseRef: initialBranch, + file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind }, + }); + const patch = scoped.sources.find((item) => item.kind === source.kind)!; + assert.deepStrictEqual(patch.files, [file]); + assert.include(patch.diff, `b/${file.path}`); + } } } }), @@ -2192,18 +2397,19 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + it.effect("keeps the patch format clients parse when the repository changes it", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); const driver = yield* GitVcsDriver.GitVcsDriver; yield* git(cwd, ["config", "diff.noprefix", "true"]); yield* git(cwd, ["config", "diff.mnemonicPrefix", "true"]); + yield* git(cwd, ["config", "diff.suppressBlankEmpty", "true"]); yield* git(cwd, ["checkout", "-b", "feature/noprefix"]); - yield* writeTextFile(cwd, "README.md", "# committed change\n"); + yield* writeTextFile(cwd, "README.md", "# test\n\ncommitted change\n"); yield* git(cwd, ["add", "README.md"]); yield* git(cwd, ["commit", "-m", "committed change"]); - yield* writeTextFile(cwd, "README.md", "# dirty change\n"); + yield* writeTextFile(cwd, "README.md", "# test\n\ndirty change\n"); yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); const preview = yield* driver.getReviewDiffPreview({ @@ -2215,6 +2421,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const workingTree = preview.sources.find((source) => source.kind === "working-tree")?.diff; const branchRange = preview.sources.find((source) => source.kind === "branch-range")?.diff; assert.include(workingTree, "diff --git a/README.md b/README.md"); + assert.include(workingTree, " # test\n \n-committed change\n+dirty change\n"); assert.include(workingTree, "+++ b/untracked.txt"); assert.include(branchRange, "diff --git a/README.md b/README.md"); }), @@ -2337,23 +2544,31 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("preserves a staged deletion when the removed path still exists", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* writeTextFile(cwd, "removed.txt", "remove me\n"); - yield* git(cwd, ["add", "removed.txt"]); - yield* git(cwd, ["commit", "-m", "add removable file"]); - yield* git(cwd, ["rm", "--cached", "removed.txt"]); + it.effect.each([ + { stagedCopy: false, expected: ["deleted file mode", "-remove me"] }, + { stagedCopy: true, expected: ["rename from removed.txt", "rename to copy.txt"] }, + ])( + "preserves a staged deletion when the removed path still exists (staged copy: $stagedCopy)", + ({ stagedCopy, expected }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "removed.txt", "remove me\n"); + yield* git(cwd, ["add", "removed.txt"]); + yield* git(cwd, ["commit", "-m", "add removable file"]); + yield* git(cwd, ["rm", "--cached", "removed.txt"]); + if (stagedCopy) { + yield* writeTextFile(cwd, "copy.txt", "remove me\n"); + yield* git(cwd, ["add", "copy.txt"]); + } - const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); - const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; - assert.include(diff, "deleted file mode"); - assert.include(diff, "-remove me"); - assert.notInclude(diff, "new file mode"); - }), + for (const text of expected) assert.include(diff, text); + assert.notInclude(diff, "new file mode"); + }), ); it.effect.each([false, true])( @@ -2634,11 +2849,12 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reads range patches from source without running textconv", () => + it.effect("reads range patches from source without textconv or color", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); yield* git(cwd, ["config", "diff.probe.textconv", "touch textconv-ran; echo converted"]); + yield* git(cwd, ["config", "color.ui", "always"]); yield* git(cwd, ["checkout", "-b", "feature/range"]); yield* writeTextFile(cwd, ".gitattributes", "*.doc diff=probe\n"); yield* writeTextFile(cwd, "notes.doc", "source line\n"); @@ -2656,12 +2872,16 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.include(context.diffPatch, "+plain line"); assert.include(context.diffPatch, "Binary files /dev/null and b/binary.dat differ"); assert.include(context.diffSummary, "notes.doc"); + for (const output of [context.commitSummary, context.diffSummary, context.diffPatch]) { + assert.notInclude(output, "\u001b["); + } assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); yield* writeTextFile(cwd, "notes.doc", "updated source line\n"); const staged = yield* driver.prepareCommitContext(cwd, ["notes.doc"]); assert.include(staged?.stagedPatch ?? "", "+updated source line"); assert.notInclude(staged?.stagedPatch ?? "", "converted"); + assert.notInclude(staged?.stagedPatch ?? "", "\u001b["); assert.include(staged?.stagedSummary ?? "", "notes.doc"); assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); }), @@ -2879,6 +3099,70 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("does not guess a Changes base for a branch renamed during the status read", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fork = yield* makeTmpDir(); + const upstream = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(fork, ["init", "--bare"]); + yield* git(upstream, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", fork]); + yield* git(cwd, ["remote", "add", "upstream", upstream]); + yield* git(cwd, ["push", "origin", "main"]); + yield* writeTextFile(cwd, "upstream.txt", "work missing from the fork\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "upstream work"]); + yield* git(cwd, ["push", "upstream", "main"]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["fetch", "upstream"]); + yield* git(cwd, ["checkout", "-b", "t3/temporary"]); + yield* git(cwd, ["config", "branch.t3/temporary.t3-merge-base", "main"]); + yield* git(cwd, [ + "config", + "branch.t3/temporary.t3-merge-base-ref", + "refs/remotes/upstream/main", + ]); + yield* writeTextFile(cwd, "feature.txt", "thread work\n"); + + const outer = yield* GitVcsDriver.GitVcsDriver; + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const renameClaimed = yield* Ref.make(false); + const renamed = yield* Deferred.make(); + // Rename after `git status` has read HEAD, as the thread launcher does after the first turn. + // Base config reads start only after status completes; hold every one until the rename lands. + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (ChildProcess.isStandardCommand(command) && command.args[0] === "config") { + if (!(yield* Ref.getAndSet(renameClaimed, true))) { + yield* git(cwd, ["branch", "-m", "t3/temporary", "feature/named"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, outer), + Effect.orDie, + ); + yield* Deferred.succeed(renamed, undefined); + } + yield* Deferred.await(renamed); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + + assert.strictEqual(status.branch, "t3/temporary"); + assert.deepStrictEqual(status.branchChanges, { + baseRef: null, + insertions: 1, + deletions: 0, + }); + }), + ); + it.effect("keeps local branch bases consistent when tags share their names", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3025,16 +3309,24 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("repository status", () => { - it.effect("reads the current branch by name when a tag shares it", () => + it.effect("reads the current branch and upstream by name when other refs share them", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["checkout", "-b", "release"]); + yield* git(cwd, ["push", "-u", "origin", "release"]); + yield* git(cwd, ["commit", "--allow-empty", "-m", "unpushed"]); + yield* git(cwd, ["branch", "origin/release"]); yield* git(cwd, ["tag", "release"]); const driver = yield* GitVcsDriver.GitVcsDriver; const remoteStatus = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); assert.equal(remoteStatus.branch, "release"); + assert.equal(remoteStatus.upstreamRef, "origin/release"); + assert.equal(remoteStatus.aheadCount, 1); const preview = yield* driver.getReviewDiffPreview({ cwd }); const branchRange = preview.sources.find((source) => source.kind === "branch-range"); assert.equal(branchRange?.headRef, "release"); @@ -3249,7 +3541,9 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { command.args.includes("ls-files") && command.args.includes("--others") ) { - untrackedScanRoots.push(command.args.slice(command.args.indexOf("--") + 1)); + untrackedScanRoots.push( + command.args.slice(command.args.indexOf("--") + 1).map(String), + ); } return delegate.spawn(command); }); @@ -3988,6 +4282,38 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { } }), ); + + // regression: #27749 hooks inherited the driver's forced LC_ALL=C + it.effect("runs post-checkout hooks under the server's own locale", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const serverLocale = process.env.LC_ALL ?? "unset"; + const seenLocale = path.join(yield* makeTmpDir("git-hook-locale-"), "lc-all"); + yield* writeTextFile( + cwd, + "hooks/post-checkout", + `#!/bin/sh\necho "\${LC_ALL-unset}" > '${seenLocale}'\n[ "\${LC_ALL-unset}" = '${serverLocale}' ]\n`, + ); + yield* fs.chmod(path.join(cwd, "hooks/post-checkout"), 0o755); + yield* git(cwd, ["config", "core.hooksPath", path.join(cwd, "hooks")]); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "locale"); + + const result = yield* driver + .createWorktree({ cwd, path: worktreePath, refName: initialBranch, newRefName: "locale" }) + .pipe(Effect.result); + + assert.equal( + (yield* fs.readFileString(seenLocale)).trim(), + serverLocale, + "post-checkout hook should see the server's LC_ALL", + ); + assert.isTrue(Result.isSuccess(result), "worktree creation failed in the hook"); + }), + ); it.effect.each(["commit", "short-commit", "tag", "revision"] as const)( "creates a worktree from a %s without recording it as a PR base", (kind) => @@ -4023,6 +4349,38 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + // regression: #28354 a failed worktree add left its new branch behind and blocked retries + it.effect("removes the new branch when the worktree cannot be added", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const path = yield* Path.Path; + const occupiedPath = yield* makeTmpDir("git-worktrees-"); + yield* writeTextFile(occupiedPath, "existing.txt", "occupied\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const failed = yield* driver + .createWorktree({ + cwd, + path: occupiedPath, + refName: initialBranch, + newRefName: "t3/collide", + }) + .pipe(Effect.result); + + assert.isTrue(Result.isFailure(failed)); + assert.equal(yield* git(cwd, ["branch", "--list", "t3/collide"]), ""); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "retry"); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "t3/collide", + }); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "t3/collide"); + }), + ); + it.effect("creates parallel worktrees in one repository without racing on its config", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4041,7 +4399,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const countingSpawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { - const args = ChildProcess.isStandardCommand(command) ? command.args : []; + const args = ChildProcess.isStandardCommand(command) ? command.args.map(String) : []; const writesConfig = (args.includes("worktree") && args.includes("add") && args.includes("-b")) || (args[0] === "branch" && @@ -4802,7 +5160,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { - const args = ChildProcess.isStandardCommand(command) ? command.args : []; + const args = ChildProcess.isStandardCommand(command) ? command.args.map(String) : []; if (args[0] === "remote" && args[1] === "-v") { listings += 1; if (listings === 2) yield* Deferred.succeed(bothListed, undefined); @@ -6017,7 +6375,7 @@ describe("Windows long path configuration", () => { ) { const layer = GitVcsDriver.layer.pipe( Layer.provide(layerServerConfig), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provide(Layer.succeed(HostProcess.Platform, platform)), ); return yield* Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a084664bc447c..4ace91f8017d2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2,7 +2,7 @@ import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -81,7 +81,10 @@ const REVIEW_BRANCH_SUBJECT_LIMIT = 300; const REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES = 128 * 1024; // Client patch parsers require a/ and b/ prefixes; override user diff prefix settings. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; +// Client patch parsers drop blank context lines that diff.suppressBlankEmpty prints without a space. const REVIEW_DIFF_ARGS = [ + "-c", + "diff.suppressBlankEmpty=false", "diff", "--find-renames", "--no-color", @@ -943,7 +946,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig.ServerConfig; const isServerInternalPath = yield* makeIsServerInternalPath; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const hostPlatform = yield* HostProcess.Platform; const serviceScope = yield* Effect.scope; const statusFetches = new Map>(); @@ -1183,7 +1186,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...options, env: { ...options.env, - LC_ALL: "C", + // English diagnostics without overriding LC_CTYPE, which hooks inherit. + LANGUAGE: "C", }, }); @@ -1293,13 +1297,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); }); + // `--abbrev-ref` prints `remotes/origin/` when a local `origin/` branch exists. const readCurrentUpstreamRef = Effect.fn("readCurrentUpstreamRef")(function* (cwd: string) { const upstreamRef = yield* runGitStdout( "GitVcsDriver.resolveCurrentUpstream", cwd, - ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + ["rev-parse", "--symbolic-full-name", "@{upstream}"], true, - ).pipe(Effect.map((stdout) => stdout.trim())); + ).pipe(Effect.map((stdout) => stdout.trim().replace(/^refs\/(?:remotes|heads)\//, ""))); if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { return null; @@ -1431,22 +1436,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); }); + const readWorktreeRoot = Effect.fn("readWorktreeRoot")(function* (cwd: string) { + const result = yield* executeGit( + "GitVcsDriver.resolveRepositoryPaths.worktreeRoot", + cwd, + ["rev-parse", "--show-toplevel"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ); + // Git terminates --show-toplevel with exactly one "\n" on every platform (stdout is + // binary mode on Windows). Whitespace before it belongs to the path and must survive, + // because this root becomes the editor target for every repository-relative status path. + const output = result.stdout.endsWith("\n") ? result.stdout.slice(0, -1) : result.stdout; + return result.exitCode === 0 && output.length > 0 + ? path.normalize(path.isAbsolute(output) ? output : path.resolve(cwd, output)) + : null; + }); + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( cwd: string, ) { const gitCommonDir = yield* readGitCommonDir(cwd); if (gitCommonDir === null) return null; - const [worktreeRootResult, currentBranchResult] = yield* Effect.all( + const [worktreeRoot, currentBranchResult] = yield* Effect.all( [ - executeGit( - "GitVcsDriver.resolveRepositoryPaths.worktreeRoot", - cwd, - ["rev-parse", "--show-toplevel"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), + readWorktreeRoot(cwd), executeGit( "GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd, @@ -1459,20 +1475,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: 2 }, ); - // Git terminates --show-toplevel with exactly one "\n" on every platform (stdout is - // binary mode on Windows). Whitespace before it belongs to the path and must survive, - // because this root becomes the editor target for every repository-relative status path. - const worktreeRootOutput = worktreeRootResult.stdout.endsWith("\n") - ? worktreeRootResult.stdout.slice(0, -1) - : worktreeRootResult.stdout; - const worktreeRoot = - worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 - ? path.normalize( - path.isAbsolute(worktreeRootOutput) - ? worktreeRootOutput - : path.resolve(cwd, worktreeRootOutput), - ) - : null; // Git reports physical roots, so resolve project aliases before comparing. const isAtRepositoryRoot = worktreeRoot === null @@ -1492,6 +1494,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } satisfies GitRepositoryPaths; }); + const resolveReviewWorktreeRoot = Effect.fn("resolveReviewWorktreeRoot")(function* (cwd: string) { + const worktreeRoot = yield* readWorktreeRoot(cwd); + if (worktreeRoot !== null) return { worktreeRoot, currentBranch: null }; + // Full discovery tells bare and non-repository directories apart from real Git failures. + return yield* resolveRepositoryPathsUncached(cwd); + }); + const repositoryPathsCache = yield* Cache.makeWith( (cwd: string) => resolveRepositoryPathsUncached(cwd), { @@ -2032,7 +2041,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const divergence = yield* executeGit( "GitVcsDriver.statusDetailsRemote.divergence", cwd, - ["rev-list", "--left-right", "--count", `HEAD...${upstreamRef}`], + ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], { allowNonZeroExit: true }, ); if (divergence.exitCode === 0) { @@ -2529,7 +2538,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedPatch = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, - ["diff", "--no-ext-diff", "--no-textconv", "--cached", "--patch", "--minimal"], + [ + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--cached", + "--patch", + "--minimal", + ], { env, maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, @@ -2922,7 +2939,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", "--end-of-options", commitRange], + ["log", "--oneline", "--no-color", "--end-of-options", commitRange], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2931,7 +2948,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", "--end-of-options", diffRange], + ["diff", "--stat", "--no-color", "--end-of-options", diffRange], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2942,6 +2959,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, [ "diff", + "--no-color", "--no-ext-diff", "--no-textconv", "--patch", @@ -2974,7 +2992,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.stagedDeletions", cwd, - ["diff", "--cached", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"], + [ + "diff", + "--cached", + "--no-renames", + "--name-only", + "--diff-filter=D", + "-z", + "HEAD", + "--", + ], { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ), runGitStdout("GitVcsDriver.readUnifiedWorkingTreeReviewDiff.indexPath", cwd, [ @@ -3144,12 +3171,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readonly untrackedRoots: ReadonlyArray; }, ) { - const { baseRef, mergeBase } = yield* resolveReviewMergeBase( + const resolved = yield* resolveReviewMergeBase( cwd, branch, undefined, options?.knownOriginDefaultBranch ?? null, ); + // A rename after `git status` read HEAD moves the base config away, so the base was guessed. + const { baseRef, mergeBase } = + branch === null || (yield* branchExists(cwd, branch)) + ? resolved + : { baseRef: null, mergeBase: "HEAD" }; const roots = options?.untrackedRoots ?? []; // Bound pathspec matching work and stay below Windows' command-line limit. const pathspecs = @@ -3345,7 +3377,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const patchLimit = input.file ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES; - const repository = yield* resolveRepositoryPathsUncached(input.cwd).pipe( + const needsCurrentBranch = + input.commitSha === undefined && + input.file?.sourceKind !== "commit" && + input.file?.sourceKind !== "working-tree"; + const repository = yield* ( + needsCurrentBranch + ? resolveRepositoryPathsUncached(input.cwd) + : resolveReviewWorktreeRoot(input.cwd) + ).pipe( Effect.catchTags({ GitCommandError: (error) => isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), @@ -3361,7 +3401,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const cwd = repository.worktreeRoot; const hashDiff = (diff: string, files: ReadonlyArray) => - crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( + digest.digest(Digest.sha256, new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( Effect.map(Hex.encode), Effect.mapError( (cause) => @@ -4142,24 +4182,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let directoryName = targetBranch.replace(/\//g, "-"); // Leave room for files beneath the worktree on Windows, regardless of branch length. if (directoryName.length > 40) { - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(targetBranch)) - .pipe(Effect.orDie); + const hash = yield* digest.digest(Digest.sha256, new TextEncoder().encode(targetBranch)); // Avoid splitting a surrogate pair, which Git would list back as a different path. const prefix = directoryName.slice(0, 23).replace(/[\uD800-\uDBFF]$/, ""); - directoryName = `${prefix}-${Hex.encode(digest).slice(0, 16)}`; + directoryName = `${prefix}-${Hex.encode(hash).slice(0, 16)}`; } worktreePath = path.join(parentDir, repoName, directoryName); } - if (input.newRefName) { + const newRefName = input.newRefName; + if (newRefName) { // Kept apart from the checkout so the shared-config lock only covers the tracking write. yield* createBranchAt( "GitVcsDriver.createWorktree.createBranch", - { cwd: input.cwd, branch: input.newRefName, startPoint: input.refName }, + { cwd: input.cwd, branch: newRefName, startPoint: input.refName }, "git worktree add failed", ); } - const args = ["worktree", "add", "--", worktreePath, input.newRefName ?? input.refName]; + const args = ["worktree", "add", "--", worktreePath, newRefName ?? input.refName]; const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; @@ -4197,6 +4236,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } : {}), }, + ).pipe( + Effect.onError(() => + newRefName + ? deleteLocalBranch({ cwd: input.cwd, refName: newRefName, force: true }).pipe( + Effect.catch((cause) => + Effect.logWarning("could not remove the branch of a failed worktree", { cause }), + ), + ) + : Effect.void, + ), ); if (progress?.onWorktreeClaimed) { @@ -4255,7 +4304,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* timeoutMs: null, ...(onSubmoduleLine ? { - env: { LC_ALL: "C" }, + env: { LANGUAGE: "C" }, progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, } : {}), @@ -4282,7 +4331,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - const newRefName = input.newRefName; if (newRefName && base) { yield* Effect.gen(function* () { yield* writeConfigValue({ diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index b6f7dae813744..c13d44f2d813a 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -1,4 +1,5 @@ import { assert, it, describe } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -26,7 +27,7 @@ const DETECT_COMMAND = "rev-parse --is-inside-work-tree --show-toplevel --git-co describe("VcsDriverRegistry", () => { const makeDiskBackedLayer = (rootDir: string, calls: string[]) => Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provide( Layer.mock(VcsProjectConfig.VcsProjectConfig)({ resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), @@ -57,7 +58,10 @@ describe("VcsDriverRegistry", () => { ); } return processOutput(""); - }).pipe(Effect.provide(NodeServices.layer), Effect.orDie), + }).pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + Effect.orDie, + ), }), ), ); @@ -79,7 +83,7 @@ describe("VcsDriverRegistry", () => { assert.equal(yield* registry.detect({ cwd: repoDir }), null); assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("detects again when git init creates a repository inside a cached one", () => @@ -103,7 +107,7 @@ describe("VcsDriverRegistry", () => { projectDir, ); }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("detects a repository created after a negative lookup", () => @@ -122,6 +126,6 @@ describe("VcsDriverRegistry", () => { assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 212415171bdc6..199ded803ee18 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -8,10 +8,12 @@ import * as Cause from "effect/Cause"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; @@ -316,6 +318,60 @@ describe("VcsProcess.run", () => { }), ); + it.effect("leaves git a slot while slow forge CLI processes are running", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const service = yield* VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + if (input.command !== "git") yield* Deferred.await(gate); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }), + ), + ); + + const forgeRuns = yield* Effect.forEach(Array.from({ length: 12 }), () => + service + .run({ + operation: "synthetic.forge.listChangeRequests", + command: "tea", + args: ["pulls", "list"], + cwd: "/workspace", + }) + .pipe(Effect.forkChild({ startImmediately: true })), + ); + const checkpoint = yield* service + .run({ + ...baseInput, + operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, + args: ["rev-parse", "HEAD"], + }) + .pipe(Effect.timeoutOption("1 second"), Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + + const gitResult = yield* Fiber.join(checkpoint); + yield* Deferred.succeed(gate, undefined); + yield* Fiber.joinAll(forgeRuns); + expect( + Option.isSome(gitResult), + "git waited behind forge CLI runs that held every VCS process slot", + ).toBe(true); + }), + ); + it.effect("keeps git from opening a folder laid out as a bare repository", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 00218397481ff..c668a35e99272 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -65,6 +65,8 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const VCS_PROCESS_CONCURRENCY = 8; +// Forge CLIs can take seconds per call; cap them together so git, checkpoints included, keeps slots. +const NON_GIT_PROCESS_CONCURRENCY = VCS_PROCESS_CONCURRENCY / 2; /** Shown to users when a Git command fails because another process holds the index lock. */ export const GIT_INDEX_LOCKED_DETAIL = @@ -183,6 +185,7 @@ export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcess.Platform; const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); + const nonGitProcesses = yield* Semaphore.make(NON_GIT_PROCESS_CONCURRENCY); const keyedProcesses = new Map(); const keyedPermit = (concurrency: NonNullable) => { let semaphore = keyedProcesses.get(concurrency.key); @@ -365,9 +368,8 @@ export const make = Effect.gen(function* () { }), ); } - return yield* input.concurrency === undefined - ? bounded - : keyedPermit(input.concurrency)(bounded); + const shared = input.command === "git" ? bounded : nonGitProcesses.withPermits(1)(bounded); + return yield* input.concurrency === undefined ? shared : keyedPermit(input.concurrency)(shared); }); return VcsProcess.of({ run }); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d861935fe17cc..154a260eea0f4 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -675,6 +675,29 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("keeps matches past the truncated part of a long line", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-long-line-" }); + yield* writeTextFile(cwd, "dist/app.min.js", `${"x".repeat(600)} needleSuffix\n`); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const search = (wholeWord: boolean) => + workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 100, + caseSensitive: true, + wholeWord, + useRegex: false, + }); + + expect((yield* search(false)).matches).toEqual([ + expect.objectContaining({ path: "dist/app.min.js", lineNumber: 1 }), + ]); + expect((yield* search(true)).matches).toEqual([]); + }), + ); + it.effect("keeps answering from the live index when a refresh outlasts the scan wait", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-refresh-" }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 786d55dbe0555..3306e420b35c5 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -3,6 +3,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -66,7 +67,7 @@ const layerTest = Layer.empty.pipe( prefix: "t3-workspace-files-test-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const makeTempDir = Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index feb7f0ab7ecfc..8345d7d57207a 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -506,7 +506,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), ); - if (matchRanges.length === 0) continue; + // fff reports hits past its 512-char lineContent cap with no ranges, so whole words can't be verified there. + if (input.wholeWord && matchRanges.length === 0) continue; matches.push({ path: toPosixPath(match.relativePath), lineNumber: match.lineNumber, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index f13eabede8294..ba84dc1cad73e 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -6,6 +6,7 @@ import * as ProjectLoom from "./project/loom/ProjectLoom.ts"; import * as CodeIntelligence from "./project/knowledge/CodeIntelligence.ts"; import * as FileHistory from "./project/knowledge/FileHistory.ts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -15,6 +16,7 @@ import { ThreadId, ProviderDriverKind, ProviderInstanceId, + providerPoolInstanceId, WS_METHODS, WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, @@ -32,6 +34,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; @@ -48,6 +51,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as NativeChildWorkQuery from "./orchestration-v2/NativeChildWorkQuery.ts"; import * as CommandOutputHub from "./orchestration-v2/CommandOutputHub.ts"; import * as CodexInstallation from "./provider/CodexInstallation.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -78,6 +82,8 @@ import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.t import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import { withProviderPools } from "./provider/providerPoolSnapshot.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -161,6 +167,7 @@ it.effect("resends late editors without rolling back updates already sent", () = Deferred.await(settingsSent).pipe(Effect.as("file-explorer" as const)), }, { resolveTargets: () => Effect.succeed([]) }, + { resolve: () => Effect.succeed([]) }, ).pipe( Stream.tap((event) => event.type === "settingsUpdated" ? Deferred.succeed(settingsSent, undefined) : Effect.void, @@ -192,6 +199,7 @@ it.effect("resends a reveal kind and remote targets that outlast the snapshot ti resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), }, { resolveTargets: () => Deferred.await(finishDiscovery) }, + { resolve: () => Effect.succeed([]) }, ).pipe(Stream.runCollect, Effect.forkScoped({ startImmediately: true })); yield* TestClock.adjust("6 seconds"); yield* Deferred.succeed(finishDiscovery, targets); @@ -429,6 +437,131 @@ it.effect("coalesces streaming replay before synchronization and the live tail", }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); +it.effect("shell groups read distinct threads once and keep each event's own sequence", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-10-27T00:00:00.000Z"; + const insert = (aggregateKind: "thread" | "project", streamId: string, version: number) => + sql<{ sequence: number }>` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) VALUES ( + ${`shell-group:${streamId}:${version}`}, ${aggregateKind}, ${streamId}, ${version}, + ${aggregateKind === "thread" ? "provider-session.detached" : "project.meta-updated"}, + ${now}, 'server', ${JSON.stringify( + aggregateKind === "thread" + ? { providerSessionId: "session:shell-group", detachedAt: now } + : { projectId: streamId, updatedAt: now }, + )}, '{}', 2 + ) RETURNING sequence + `.pipe(Effect.map(([row]) => row!.sequence)); + const shell = (id: string, archivedAt: string | null = null) => + ({ id: ThreadId.make(id), archivedAt }) as unknown as OrchestrationV2ThreadShell; + const project = { + id: ProjectId.make("project:group"), + } as unknown as OrchestrationProjectShell; + + const after = yield* insert("project", "project:before", 0); + yield* insert("thread", "thread:active", 0); + const archived = yield* insert("thread", "thread:archived", 0); + const projectUpdated = yield* insert("project", project.id, 0); + const active = yield* insert("thread", "thread:active", 1); + const missing = yield* insert("thread", "thread:missing", 0); + + const shellReads: Array | undefined> = []; + let batchFails = false; + const collect = (afterSequence: number) => + subscribeOrchestrationV2Shell({ afterSequence, requestCompletionMarker: true }).pipe( + Effect.flatMap((stream) => + stream.pipe( + Stream.takeUntil((item) => item.kind === "synchronized"), + Stream.runCollect, + ), + ), + Effect.provideService(ThreadManagement.ThreadManagementService, { + listActiveSubagentThreadIds: () => Effect.succeed([]), + getShellSnapshot: (options?: { readonly threadIds?: ReadonlyArray }) => + Effect.suspend(() => { + shellReads.push(options?.threadIds); + return batchFails + ? Effect.fail("batch failed") + : Effect.succeed({ + schemaVersion: 1, + snapshotSequence: Number.MAX_SAFE_INTEGER, + threads: [shell("thread:active")], + archivedThreads: [shell("thread:archived", now)], + }); + }), + getThreadShell: (threadId: string) => + Effect.sync(() => { + shellReads.push([`scalar:${threadId}`]); + return threadId === "thread:missing" + ? null + : shell(threadId, threadId === "thread:archived" ? now : null); + }), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectService.ProjectService, { + getShell: () => Effect.succeedSome(project), + } as unknown as ProjectService.ProjectService["Service"]), + Effect.provideServiceEffect( + ProjectEnrichment.ProjectEnrichmentService, + PubSub.unbounded().pipe( + Effect.map( + (changes) => + ({ + subscribeChanges: PubSub.subscribe(changes), + }) as unknown as ProjectEnrichment.ProjectEnrichmentService["Service"], + ), + ), + ), + ); + + const collectSummary = (afterSequence: number) => + collect(afterSequence).pipe( + Effect.map((items) => + items.flatMap((item): ReadonlyArray> => + item.kind === "thread.updated" + ? [[item.kind, item.sequence, item.thread.id]] + : item.kind === "thread.removed" + ? [[item.kind, item.sequence, item.threadId]] + : item.kind === "project.updated" + ? [[item.kind, item.sequence, item.project.id]] + : [], + ), + ), + ); + const expectedGroup = [ + ["thread.removed", archived, "thread:archived"], + ["project.updated", projectUpdated, project.id], + ["thread.updated", active, "thread:active"], + ["thread.removed", missing, "thread:missing"], + ]; + + assert.deepEqual(yield* collectSummary(after), expectedGroup); + assert.deepEqual(shellReads, [["thread:archived", "thread:active", "thread:missing"]]); + + shellReads.length = 0; + assert.deepEqual(yield* collectSummary(active), [ + ["thread.removed", missing, "thread:missing"], + ]); + assert.deepEqual(shellReads, [["scalar:thread:missing"]]); + + shellReads.length = 0; + batchFails = true; + assert.deepEqual(yield* collectSummary(after), expectedGroup); + assert.deepEqual(shellReads, [ + ["thread:archived", "thread:active", "thread:missing"], + ["scalar:thread:archived"], + ["scalar:thread:active"], + ["scalar:thread:missing"], + ]); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + it.effect("shell snapshots omit idle children and older settled threads only on request", () => Effect.gen(function* () { const shell = ( @@ -551,6 +684,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(AntigravityInstallation.AntigravityInstallation)({ managedDirectory: "/unused" }), Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({}), + Layer.mock(NativeChildWorkQuery.NativeChildWorkQuery)({}), Layer.mock(CommandOutputHub.CommandOutputHub)({}), Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "/unused" }), Layer.mock(DeviceService.DeviceService)({}), @@ -627,11 +761,18 @@ const layerConfigSources = Layer.mergeAll( ServerConfigModule.layerTest(process.cwd(), { prefix: "t3-config-subscription-test-" }), ), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); -it.effect.each(["pending", "cached"] as const)( - "%s remote discovery preserves initial config and updates through discovery and ACK", +it.effect.each([ + "pending", + "cached", + "direct-pending", + "direct-cached", + "direct-failed", + "direct-interrupted", +] as const)( + "%s endpoint discovery preserves initial config and updates through discovery and ACK", (discovery) => Effect.gen(function* () { const keybindings = yield* Keybindings.Keybindings; @@ -650,6 +791,15 @@ it.effect.each(["pending", "cached"] as const)( const remoteDiscoveryStarted = yield* Deferred.make(); const finishRemoteDiscovery = yield* Deferred.make(); const targets = [{ kind: "mdns" as const, host: "test.local" }]; + const direct = discovery.startsWith("direct-"); + const bound = [{ kind: "lan" as const, httpBaseUrl: "http://192.168.1.10:3773/" }]; + const endpoints = [ + ...bound, + { kind: "tailnet" as const, httpBaseUrl: "https://test.ts.net/" }, + ]; + const directStarted = yield* Deferred.make(); + const finishDirect = yield* Deferred.make(); + const directStopped = yield* Deferred.make(); const responses = yield* Queue.unbounded(); let editorDiscoveryCalls = 0; const configRpcServices = Layer.mergeAll( @@ -695,12 +845,30 @@ it.effect.each(["pending", "cached"] as const)( resolveTargets: () => Deferred.succeed(remoteDiscoveryStarted, undefined).pipe( Effect.andThen( - discovery === "cached" ? Effect.void : Deferred.await(finishRemoteDiscovery), + discovery === "cached" || direct + ? Effect.void + : Deferred.await(finishRemoteDiscovery), ), Effect.as(targets), ), }), - Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(DirectEndpoints.DirectEndpoints)({ + resolveAvailable: () => Effect.succeed(discovery === "direct-cached" ? endpoints : bound), + resolve: () => + direct + ? Deferred.succeed(directStarted, undefined).pipe( + Effect.andThen( + discovery === "direct-cached" ? Effect.void : Deferred.await(finishDirect), + ), + Effect.andThen( + discovery === "direct-failed" + ? Effect.die("probe failed") + : Effect.succeed(endpoints), + ), + Effect.onExit(() => Deferred.succeed(directStopped, undefined)), + ) + : Effect.succeed(bound), + }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), @@ -780,7 +948,14 @@ it.effect.each(["pending", "cached"] as const)( const snapshot = yield* Queue.take(responses); assert.equal(snapshot.type, "snapshot"); if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); - assert.deepEqual(snapshot.config.remoteOpenTargets, discovery === "cached" ? targets : []); + assert.deepEqual( + snapshot.config.remoteOpenTargets, + discovery === "cached" || direct ? targets : [], + ); + assert.deepEqual( + snapshot.config.directEndpoints, + discovery === "direct-cached" ? endpoints : bound, + ); assert.isFalse(snapshot.config.settings.defaultAutoPull); assert.deepEqual(snapshot.config.keybindings, initialKeybindings); const duringAck = yield* keybindings.upsertKeybindingRule({ @@ -846,7 +1021,31 @@ it.effect.each(["pending", "cached"] as const)( assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings); yield* acknowledge; } + if (direct) { + yield* Deferred.await(directStarted); + if (discovery === "direct-pending") { + yield* Deferred.succeed(finishDirect, undefined); + const resolved = yield* Queue.take(responses); + assert.equal(resolved.type, "snapshot"); + if (resolved.type !== "snapshot") return yield* Effect.die("Missing direct endpoints"); + assert.deepEqual(resolved.config.directEndpoints, endpoints); + assert.deepEqual( + resolved.config.settings, + ServerSettings.redactServerSettingsForClient(latestSettings), + ); + assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings); + yield* acknowledge; + } else if (discovery === "direct-failed") { + yield* Deferred.succeed(finishDirect, undefined); + yield* Deferred.await(directStopped); + yield* settings.updateSettings({ defaultAutoPull: false }); + const event = yield* Queue.take(responses); + assert.equal(event.type, "settingsUpdated"); + yield* acknowledge; + } + } yield* server.write(0, { _tag: "Interrupt", requestId, interruptors: [] }); + if (direct) yield* Deferred.await(directStopped); }).pipe( Effect.provide( Layer.mergeAll( @@ -859,164 +1058,277 @@ it.effect.each(["pending", "cached"] as const)( ), ); -it.effect("on-demand clients get provider config without workspace snapshots", () => - Effect.gen(function* () { - const instanceId = ProviderInstanceId.make("claudeAgent"); - const snapshotFor = (cwd: string, checkedAt: string) => ({ - cwd, - checkedAt, - slashCommands: [{ name: `${cwd.slice(1)}-command` }], - skills: [], - }); - const provider: ServerProvider = { - instanceId, - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-10-25T00:00:00.000Z", - models: [], - slashCommands: [{ name: "machine-command" }], - skills: [], - workspaceSnapshots: [ - snapshotFor("/a", "2026-10-25T00:00:00.000Z"), - snapshotFor("/b", "2026-10-25T00:00:00.000Z"), - ], - }; - const providerChanges = yield* Queue.unbounded>(); - const pulled = yield* Queue.unbounded(); - const responses = yield* Queue.unbounded(); - const configRpcServices = Layer.mergeAll( - layerUnusedConfigRpcServices, - Layer.mock(ExternalLauncher.ExternalLauncher)({ - resolveAvailableEditors: () => Effect.succeed([]), - // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. - resolveFileManagerRevealKind: () => Effect.succeed(undefined), - }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([provider]), - getPublishedProviders: Effect.succeed([provider]), - streamPublishedChanges: Stream.fromQueue(providerChanges).pipe( - Stream.tap(() => Queue.offer(pulled, undefined)), - ), - refreshWorkspaceSnapshot: () => Effect.succeed([provider]), - }), - Layer.mock(UsageLimitSources.UsageLimitSources)({ - current: Effect.succeed([]), - streamChanges: Stream.make([]), - }), - Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ - resolveTargets: () => Effect.succeed([]), - }), - Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), - Layer.mock(PstackPack.PstackPack)({ catalog: Effect.sync(() => undefined) }), - Layer.mock(SkillsLibrary.SkillsLibrary)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - scratchRoot: Effect.succeedNone, - namedProjectsRoot: process.cwd(), - }), - Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), - Layer.mock(ServerEnvironment.ServerEnvironment)({ - getDescriptor: Effect.succeed({ - environmentId: EnvironmentId.make("config-test"), - label: "Config test", - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0", - capabilities: { repositoryIdentity: false }, +it.effect( + "on-demand clients get provider config without workspace snapshots and refresh selected pool models", + () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("claudeAgent"); + const instanceId = ProviderInstanceId.make("claudeAgent"); + const secondInstanceId = ProviderInstanceId.make("claudeAgent_work"); + const unrelatedInstanceId = ProviderInstanceId.make("claudeAgent_unrelated"); + const poolInstanceId = providerPoolInstanceId(driver); + const snapshotFor = (cwd: string, checkedAt: string) => ({ + cwd, + checkedAt, + slashCommands: [{ name: `${cwd.slice(1)}-command` }], + skills: [], + }); + const provider: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-25T00:00:00.000Z", + models: [{ slug: "old-model", name: "Old model", isCustom: false, capabilities: null }], + slashCommands: [{ name: "machine-command" }], + skills: [], + workspaceSnapshots: [ + snapshotFor("/a", "2026-10-25T00:00:00.000Z"), + snapshotFor("/b", "2026-10-25T00:00:00.000Z"), + ], + }; + const providers = yield* Ref.make>([ + provider, + { ...provider, instanceId: secondInstanceId }, + { ...provider, instanceId: unrelatedInstanceId }, + ]); + const providerPools = { + [driver]: { enabled: true, memberInstanceIds: [instanceId, secondInstanceId] }, + }; + yield* (yield* ServerSettings.ServerSettingsService).updateSettings({ + providerInstances: { + [secondInstanceId]: { driver, enabled: true }, + [unrelatedInstanceId]: { driver, enabled: true }, + }, + providerPools, + }); + const instances = [instanceId, secondInstanceId, unrelatedInstanceId].map( + (selectedId): ProviderInstance => ({ + instanceId: selectedId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: `claude:${selectedId}` }, + displayName: undefined, + enabled: true, + snapshot: {} as ProviderInstance["snapshot"], + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + refreshModels: () => + Ref.update(providers, (current) => + current.map((entry) => + entry.instanceId === selectedId + ? { + ...entry, + models: [ + { + slug: "new-model", + name: "New model", + isCustom: false, + capabilities: null, + }, + ], + } + : entry, + ), + ), + }), + ); + const providerChanges = yield* Queue.unbounded>(); + const pulled = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const configRpcServices = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Ref.get(providers), + getPublishedProviders: Ref.get(providers).pipe( + Effect.map((current) => withProviderPools({ providers: current, providerPools })), + ), + streamPublishedChanges: Stream.fromQueue(providerChanges).pipe( + Stream.tap(() => Queue.offer(pulled, undefined)), + ), + refreshWorkspaceSnapshot: () => Ref.get(providers), + refreshInstance: () => Ref.get(providers), + invalidateModelCaches: () => Effect.void, + }), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.succeed(instances), + }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed([]), + streamChanges: Stream.make([]), + }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), }), - }), - Layer.mock(EnvironmentAuth.EnvironmentAuth)({ - getDescriptor: () => - Effect.succeed({ - policy: "loopback-browser", - bootstrapMethods: [], - sessionMethods: ["browser-session-cookie"], - sessionCookieName: "config-test-session", + Layer.mock(DirectEndpoints.DirectEndpoints)({ + resolve: () => Effect.succeed([]), + resolveAvailable: () => Effect.succeed([]), + }), + Layer.mock(PstackPack.PstackPack)({ catalog: Effect.sync(() => undefined) }), + Layer.mock(SkillsLibrary.SkillsLibrary)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedNone, + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("config-test"), + label: "Config test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, }), - }), - ); - const server = yield* RpcServer.makeNoSerialization( - RpcGroup.make(WsSubscribeServerConfigRpc, WsServerRefreshProvidersRpc), - { - ...WS_RPC_SERVER_OPTIONS, - onFromServer: (response) => - response._tag === "Chunk" - ? Effect.forEach(response.values, (value) => Queue.offer(responses, value), { - discard: true, - }) - : response._tag === "Exit" && response.exit._tag === "Success" - ? Queue.offer(responses, response.exit.value) - : Effect.void, - }, - ).pipe( - Effect.provide( - layerWsRpc( - { - sessionId: AuthSessionId.make("config-test"), - subject: "config-test", - method: "browser-session-cookie", - scopes: [], - }, - {}, - yield* ServerBrowser.ServerBrowser, - () => Effect.void, - ).pipe(Layer.provide(configRpcServices)), - ), - ); - const configRequest = RpcMessage.RequestId("config"); - yield* server.write(0, { - _tag: "Request", - id: configRequest, - tag: WS_METHODS.subscribeServerConfig, - payload: { workspaceSnapshotsOnDemand: true }, - headers: Headers.empty, - }); - const snapshot = (yield* Queue.take(responses)) as ServerConfigStreamEvent; - if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); - assert.isUndefined(snapshot.config.providers[0]?.workspaceSnapshots); - yield* server.write(0, { _tag: "Ack", requestId: configRequest }); + }), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + getDescriptor: () => + Effect.succeed({ + policy: "loopback-browser", + bootstrapMethods: [], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "config-test-session", + }), + }), + ); + const server = yield* RpcServer.makeNoSerialization( + RpcGroup.make(WsSubscribeServerConfigRpc, WsServerRefreshProvidersRpc), + { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Chunk" + ? Effect.forEach(response.values, (value) => Queue.offer(responses, value), { + discard: true, + }) + : response._tag === "Exit" && response.exit._tag === "Success" + ? Queue.offer(responses, response.exit.value) + : Effect.void, + }, + ).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("config-test"), + subject: "config-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + yield* ServerBrowser.ServerBrowser, + () => Effect.void, + ).pipe(Layer.provide(configRpcServices)), + ), + ); + const configRequest = RpcMessage.RequestId("config"); + yield* server.write(0, { + _tag: "Request", + id: configRequest, + tag: WS_METHODS.subscribeServerConfig, + payload: { workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const snapshot = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); + assert.isUndefined(snapshot.config.providers[0]?.workspaceSnapshots); + assert.deepEqual( + snapshot.config.providers + .find((entry) => entry.instanceId === poolInstanceId) + ?.models.map((model) => model.slug), + ["old-model"], + ); + yield* server.write(0, { _tag: "Ack", requestId: configRequest }); - // A rescan that only touches a snapshot sends nothing; the next real change does. - yield* Queue.offer(providerChanges, [ - { ...provider, workspaceSnapshots: [snapshotFor("/a", "2026-10-25T00:05:00.000Z")] }, - ]); - yield* Queue.take(pulled); - yield* TestClock.adjust("1 second"); - yield* Queue.offer(providerChanges, [{ ...provider, status: "warning" }]); - yield* Queue.take(pulled); - yield* TestClock.adjust("1 second"); - const update = (yield* Queue.take(responses)) as ServerConfigStreamEvent; - if (update.type !== "providerStatuses") return yield* Effect.die("Expected provider update"); - assert.equal(update.payload.providers[0]?.status, "warning"); - assert.isUndefined(update.payload.providers[0]?.workspaceSnapshots); + // A rescan that only touches a snapshot sends nothing; the next real change does. + yield* Queue.offer( + providerChanges, + withProviderPools({ + providers: (yield* Ref.get(providers)).map((entry) => + entry.instanceId === instanceId + ? { ...entry, workspaceSnapshots: [snapshotFor("/a", "2026-10-25T00:05:00.000Z")] } + : entry, + ), + providerPools, + }), + ); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + yield* Queue.offer( + providerChanges, + withProviderPools({ + providers: (yield* Ref.get(providers)).map((entry) => + entry.instanceId === instanceId ? { ...entry, status: "warning" } : entry, + ), + providerPools, + }), + ); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + const update = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (update.type !== "providerStatuses") return yield* Effect.die("Expected provider update"); + assert.equal(update.payload.providers[0]?.status, "warning"); + assert.isUndefined(update.payload.providers[0]?.workspaceSnapshots); - yield* server.write(0, { - _tag: "Request", - id: RpcMessage.RequestId("refresh"), - tag: WS_METHODS.serverRefreshProviders, - payload: { instanceId, cwd: "/b", workspaceSnapshotsOnDemand: true }, - headers: Headers.empty, - }); - const refreshed = (yield* Queue.take(responses)) as { - readonly providers: ReadonlyArray; - }; - assert.deepEqual( - refreshed.providers.map((entry) => [entry.workspaceSnapshots, entry.slashCommands]), - [[[snapshotFor("/b", "2026-10-25T00:00:00.000Z")], []]], - ); - yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.fresh(layerConfigSources), - Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), - Layer.mock(ServerBrowser.ServerBrowser)({}), + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh"), + tag: WS_METHODS.serverRefreshProviders, + payload: { instanceId, cwd: "/b", workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const refreshed = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + assert.deepEqual( + refreshed.providers.map((entry) => [entry.workspaceSnapshots, entry.slashCommands]), + [[[snapshotFor("/b", "2026-10-25T00:00:00.000Z")], []]], + ); + yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); + + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh-pool-models"), + tag: WS_METHODS.serverRefreshProviders, + payload: { + instanceId: poolInstanceId, + refreshModels: true, + workspaceSnapshotsOnDemand: true, + }, + headers: Headers.empty, + }); + const refreshedPool = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + assert.deepEqual( + refreshedPool.providers.map((entry) => [ + entry.instanceId, + entry.models.map((model) => model.slug), + ]), + [ + [instanceId, ["new-model"]], + [secondInstanceId, ["new-model"]], + [unrelatedInstanceId, ["old-model"]], + [poolInstanceId, ["new-model"]], + ], + ); + assert.isTrue( + refreshedPool.providers.every((entry) => entry.workspaceSnapshots === undefined), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), ), + Effect.scoped, ), - Effect.scoped, - ), ); // Compaction keeps the oldest and newest event per message, so a cursor older diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fa39b88ac2b6a..0a2f449899b9e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,7 +13,7 @@ import { ThreadId as ThreadIdSchema, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; @@ -89,6 +89,8 @@ import { ProjectMutationError, ProviderUploadFeedbackError, ProviderSetupError, + providerPoolDriver, + resolveActiveProviderPools, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, type ServerConfig as ClientServerConfig, @@ -126,6 +128,7 @@ import { import { RpcGroup, RpcSerialization, RpcServer } from "effect/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as NativeChildWorkQuery from "./orchestration-v2/NativeChildWorkQuery.ts"; import * as ServerConfig from "./config.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; @@ -189,9 +192,6 @@ import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; -import * as ModelManifest from "./provider/ModelManifest.ts"; -import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; -import { latestProviderVersionCacheKeys } from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import * as ProviderAccountTransfer from "./provider/ProviderAccountTransfer.ts"; @@ -288,6 +288,7 @@ import { import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import * as AgentSessionImporter from "./project/AgentSessionImporter.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; +import { requestUrl } from "./requestUrl.ts"; const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); const isProviderUploadFeedbackError = Schema.is(ProviderUploadFeedbackError); @@ -346,6 +347,7 @@ export const withLateConfigDiscovery = ( >, launcher: EditorDiscovery, remoteOpenTargets: RemoteOpenTargets.RemoteOpenTargets["Service"], + directEndpoints: Pick, ) => { const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe( Stream.filter( @@ -373,7 +375,15 @@ export const withLateConfigDiscovery = ( Stream.map((targets) => ({ type: "remoteOpenTargetsResolved" as const, targets })), ); - return Stream.merge(liveUpdates, Stream.merge(lateEditorConfig, lateRemoteOpenTargets)).pipe( + const lateDirectEndpoints = Stream.fromEffect(directEndpoints.resolve()).pipe( + Stream.catchCause(() => Stream.empty), + Stream.map((endpoints) => ({ type: "directEndpointsResolved" as const, endpoints })), + ); + + return Stream.merge( + liveUpdates, + Stream.merge(lateEditorConfig, Stream.merge(lateRemoteOpenTargets, lateDirectEndpoints)), + ).pipe( Stream.mapAccum( (): ClientServerConfig => config, (current, event): readonly [ClientServerConfig, ReadonlyArray] => { @@ -403,6 +413,13 @@ export const withLateConfigDiscovery = ( const next = { ...current, remoteOpenTargets: event.targets }; return [next, [{ version: 1, type: "snapshot", config: next }]]; } + case "directEndpointsResolved": { + if (JSON.stringify(current.directEndpoints ?? []) === JSON.stringify(event.endpoints)) { + return [current, []]; + } + const next = { ...current, directEndpoints: event.endpoints }; + return [next, [{ version: 1, type: "snapshot", config: next }]]; + } case "keybindingsUpdated": return [{ ...current, ...event.payload }, [event]]; case "providerStatuses": @@ -848,7 +865,7 @@ function shouldUseBoundedThreadSnapshot(input: { // Keep telemetry per socket; several client types can connect simultaneously. function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { - const url = HttpServerRequest.toURL(request); + const url = requestUrl(request); if (Option.isNone(url)) { return {}; } @@ -898,7 +915,7 @@ function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) function readClientOwners( request: HttpServerRequest.HttpServerRequest, ): ReadonlyArray { - const url = HttpServerRequest.toURL(request); + const url = requestUrl(request); if (Option.isNone(url)) { return []; } @@ -1263,14 +1280,37 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc const projectShellItems = Effect.fn("ws.orchestrationV2.projectShellItems")(function* ( events: ReadonlyArray, ) { + const coalesced = coalesceShellApplicationEvents(events); + const threadIds = coalesced.flatMap((stored) => + "aggregateKind" in stored ? [] : [stored.event.threadId], + ); + // A failed batch can't name its thread, so the scalar reads below report which one is bad. + const batch = + threadIds.length > 1 + ? yield* threadManagement + .getShellSnapshot({ threadIds }) + .pipe(Effect.orElseSucceed(() => null)) + : null; + const batchedShells = + batch === null + ? null + : new Map( + [...batch.threads, ...batch.archivedThreads].map( + (shell) => [shell.id, shell] as const, + ), + ); return yield* Effect.forEach( - coalesceShellApplicationEvents(events), + coalesced, (stored) => Effect.gen(function* () { if ("aggregateKind" in stored) { return yield* projectItem(stored); } - const shell = yield* threadManagement.getThreadShell(stored.event.threadId); + const threadId = stored.event.threadId; + const shell = + batchedShells === null + ? yield* threadManagement.getThreadShell(threadId) + : (batchedShells.get(threadId) ?? null); return shellStreamItemFromThreadShell({ stored, shell }); }), { concurrency: 8 }, @@ -1480,13 +1520,13 @@ export const layerWsRpc = ( const deviceHostContext = yield* Effect.context>>(); const orchestrationEngine = yield* Orchestrator.OrchestratorV2; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; // RPC client ids are only unique within one socket; terminals share viewers across sockets. - const terminalViewerPrefix = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const terminalViewerPrefix = yield* random.uuidV4(); const terminalViewerId = (metadata: { readonly client: { readonly id: number } }) => `${terminalViewerPrefix}:${metadata.client.id}`; const serverCommandId = (tag: string) => - crypto.randomUUIDv4().pipe( + random.uuidV4().pipe( Effect.orDie, Effect.map((id) => CommandId.make(`server:${tag}:${id}`)), ); @@ -1509,6 +1549,7 @@ export const layerWsRpc = ( const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const agentSessionImporter = yield* AgentSessionImporter.AgentSessionImporter; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const nativeChildWork = yield* NativeChildWorkQuery.NativeChildWorkQuery; const keybindings = yield* Keybindings.Keybindings; const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; @@ -1522,8 +1563,6 @@ export const layerWsRpc = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; - const modelManifest = yield* ModelManifest.ModelManifest; - const providerLatestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const acpRegistryCatalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const acpRegistryRuntimeCoordinator = @@ -1725,7 +1764,7 @@ export const layerWsRpc = ( provider?.models.find((candidate) => candidate.isDefault)?.slug ?? provider?.models[0]?.slug ?? "default"; - const commandId = CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)); + const commandId = CommandId.make(yield* random.uuidV4()); const launched = yield* Effect.result( startup.enqueueCommand( threadLaunch.launch({ @@ -1929,7 +1968,7 @@ export const layerWsRpc = ( const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { readonly usageLimitsCommand: boolean; readonly workspaceSnapshotsOnDemand: boolean; - readonly deferRemoteOpenTargets: boolean; + readonly deferNetworkDiscovery: boolean; }) { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getPublishedProviders; @@ -1950,10 +1989,12 @@ export const layerWsRpc = ( const discovery = yield* resolveOpenDiscoveryForConfig({ editors: externalLauncher.resolveAvailableEditors(), fileManagerRevealKind: externalLauncher.resolveFileManagerRevealKind(), - remoteOpenTargets: options.deferRemoteOpenTargets + remoteOpenTargets: options.deferNetworkDiscovery ? Effect.succeed([]) : remoteOpenTargets.resolveTargets(), - directEndpoints: directEndpoints.resolve(), + directEndpoints: options.deferNetworkDiscovery + ? directEndpoints.resolveAvailable() + : directEndpoints.resolve(), }); return { @@ -2129,6 +2170,8 @@ export const layerWsRpc = ( threadManagement.forceStopThread(input), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => readWorkflowScript({ scriptPath: input.scriptPath }), + [ORCHESTRATION_V2_WS_METHODS.getNativeChildWork]: (input) => + nativeChildWork.getNativeChildWork(input), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => threadManagement.getTurnItem(input).pipe( Effect.mapError( @@ -2389,7 +2432,7 @@ export const layerWsRpc = ( loadServerConfig({ usageLimitsCommand: false, workspaceSnapshotsOnDemand: false, - deferRemoteOpenTargets: false, + deferNetworkDiscovery: false, }), [WS_METHODS.serverSearchAcpRegistry]: (input) => acpRegistryCatalog @@ -2494,30 +2537,11 @@ export const layerWsRpc = ( /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (input) { if (input.refreshModels) { - yield* modelManifest.forceRefresh; - const instances = yield* providerInstances.listInstances; - yield* Effect.forEach( - instances.filter( - (instance) => - input.instanceId === undefined || input.instanceId === instance.instanceId, - ), - (instance) => - Effect.gen(function* () { - yield* instance.invalidateCaches ?? Effect.void; - const maintenance = yield* instance.snapshot.resolveMaintenance({ - fresh: - input.instanceId !== undefined && - input.cwd !== undefined && - input.fresh !== true, - }); - yield* Effect.forEach( - latestProviderVersionCacheKeys(maintenance), - providerLatestVersions.invalidate, - { discard: true }, - ); - }), - { concurrency: "unbounded", discard: true }, - ); + yield* providerRegistry.invalidateModelCaches({ + instanceId: input.instanceId, + cwd: input.cwd, + fresh: input.fresh, + }); } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { @@ -2539,10 +2563,24 @@ export const layerWsRpc = ( : providerRegistry.refresh(); if (input.refreshModels) { const instances = yield* providerInstances.listInstances; + const poolDriver = + input.instanceId === undefined ? null : providerPoolDriver(input.instanceId); + const selectedInstanceIds = + input.instanceId === undefined + ? undefined + : poolDriver === null + ? [input.instanceId] + : (resolveActiveProviderPools({ + providerPools: (yield* serverSettings.getSettings.pipe(Effect.orDie)) + .providerPools, + instances: providers, + }).find((pool) => pool.instanceId === input.instanceId)?.memberInstanceIds ?? + []); for (const instance of instances) { if ( !instance.refreshModels || - (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || + (selectedInstanceIds !== undefined && + !selectedInstanceIds.includes(instance.instanceId)) || !providers.some( (provider) => provider.instanceId === instance.instanceId && @@ -3320,10 +3358,16 @@ export const layerWsRpc = ( const remoteTargets = yield* Effect.forkScoped(remoteOpenTargets.resolveTargets(), { startImmediately: true, }); + const directResult = yield* Effect.forkScoped( + directEndpoints + .resolve() + .pipe(Effect.catchCause(() => directEndpoints.resolveAvailable())), + { startImmediately: true }, + ); const config = yield* loadServerConfig({ usageLimitsCommand, workspaceSnapshotsOnDemand, - deferRemoteOpenTargets: true, + deferNetworkDiscovery: true, }); const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ @@ -3432,9 +3476,15 @@ export const layerWsRpc = ( rpcInitialItems([ { version: 1 as const, type: "snapshot" as const, config: initialConfig }, ]), - withLateConfigDiscovery(initialConfig, liveUpdates, externalLauncher, { - resolveTargets: () => Fiber.join(remoteTargets), - }), + withLateConfigDiscovery( + initialConfig, + liveUpdates, + externalLauncher, + { + resolveTargets: () => Fiber.join(remoteTargets), + }, + { resolve: () => Fiber.join(directResult) }, + ), ); }), ), @@ -3533,8 +3583,8 @@ export const layer = Layer.unwrap( "/ws", Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const requestUrl = HttpServerRequest.toURL(request); - if (Option.isNone(requestUrl) || !hasCompatibleOrchestrationProtocol(requestUrl.value)) { + const url = requestUrl(request); + if (Option.isNone(url) || !hasCompatibleOrchestrationProtocol(url.value)) { return HttpServerResponse.jsonUnsafe( { code: "orchestration_protocol_incompatible", diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts deleted file mode 100644 index bb442959f1813..0000000000000 --- a/apps/web/src/assets/assetUrls.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { EnvironmentId, ThreadId, type AssetResource } from "@t3tools/contracts"; -import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; -import { describe, expect, it } from "vite-plus/test"; - -import { makeRevisionedAssetUrlFamily } from "./assetUrls"; - -const environmentId = EnvironmentId.make("environment-1"); -const resource: AssetResource = { - _tag: "workspace-file", - threadId: ThreadId.make("thread-1"), - path: "docs/diagram.png", -}; - -type MintResult = AsyncResult.AsyncResult<{ relativeUrl: string; expiresAt: number }>; - -const minted = (n: number): MintResult => - AsyncResult.success({ relativeUrl: `/a/${n}`, expiresAt: 0 }); - -// A stand-in for the asset URL query: a refresh starts a mint (counted, shown as -// waiting) and `settle` completes the latest one. -function setup(initial: MintResult) { - let mints = 0; - // Node removal runs on the registry's scheduler; `unmount` flushes it so the - // next mount of the same revision builds a fresh node. - const scheduled: Array<() => void> = []; - const registry = AtomRegistry.make({ - scheduleTask: (task) => { - scheduled.push(task); - return () => {}; - }, - }); - const server = Atom.make(initial).pipe(Atom.keepAlive); - const query = Atom.readable( - (get) => get(server), - () => { - mints += 1; - registry.set(server, AsyncResult.waiting(registry.get(server))); - }, - ).pipe(Atom.keepAlive); - const revisioned = makeRevisionedAssetUrlFamily(() => query); - const mount = (revision: string | null) => { - const release = registry.mount(revisioned({ environmentId, input: { resource }, revision })); - return () => { - release(); - for (const task of scheduled.splice(0)) task(); - }; - }; - const settle = () => registry.set(server, minted(mints + 1)); - return { mount, settle, mints: () => mints }; -} - -describe("makeRevisionedAssetUrlFamily", () => { - it("re-mints a settled URL on first read and on each new revision, not on remount", () => { - const { mount, settle, mints } = setup(minted(0)); - - mount("mutation-1")(); - expect(mints()).toBe(1); - settle(); - - mount("mutation-1")(); - expect(mints()).toBe(1); - - mount("mutation-2")(); - expect(mints()).toBe(2); - }); - - it("re-mints when a revert returns to an earlier revision", () => { - const { mount, settle, mints } = setup(minted(0)); - - mount("mutation-a")(); - settle(); - mount("mutation-b")(); - settle(); - expect(mints()).toBe(2); - - const reverted = mount("mutation-a"); - expect(mints()).toBe(3); - reverted(); - }); - - it("shares a mint in flight on the first read", () => { - const { mount, mints } = setup(AsyncResult.initial(true)); - - mount("mutation-1")(); - expect(mints()).toBe(0); - }); - - it("re-mints after a mint that was in flight when the revision changed", () => { - const { mount, settle, mints } = setup(minted(0)); - - const before = mount("mutation-1"); - settle(); - const edited = mount("mutation-2"); - expect(mints()).toBe(2); - before(); - - const stillMinting = mount("mutation-3"); - edited(); - expect(mints()).toBe(2); - - settle(); - expect(mints()).toBe(3); - settle(); - expect(mints()).toBe(3); - stillMinting(); - }); -}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 7b48ed923d72e..d14d750894ebe 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -4,12 +4,12 @@ import { assetUrlStateFromResult, blockedAssetUrlState, EMPTY_ASSET_URL_ATOM, + makeRevisionedAssetUrlFamily, resolveAssetUrl, } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { AssetResource, type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; +import { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; import { assetEnvironment } from "~/state/assets"; import { useFilesystemReadAccess } from "~/state/filesystem"; @@ -20,72 +20,6 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; export type AssetUrlState = RuntimeAssetUrlState & { readonly waiting?: boolean }; -type AssetUrlTarget = { - readonly environmentId: EnvironmentId; - readonly input: { readonly resource: AssetResource }; -}; - -const decodeRevisionedAssetUrlKey = Schema.decodeUnknownSync( - Schema.Tuple([EnvironmentId, AssetResource, Schema.NullOr(Schema.String)]), -); - -const isSettled = (result: AsyncResult.AsyncResult) => - !AsyncResult.isInitial(result) && !result.waiting; - -/** - * The URL is minted per resource and shared across revisions, so reading a - * different revision (even an earlier one after a revert) re-mints it. An - * in-flight mint is shared only on first read; after an edit the server must - * re-read the file, so the re-mint waits for that mint to settle. - */ -export function makeRevisionedAssetUrlFamily( - createUrl: ( - target: AssetUrlTarget, - ) => Atom.Atom>, -) { - // Outside the nodes: a node is dropped once unmounted, and the next read of - // its revision must still see the revisions minted since. Never pruned on - // purpose: pruning would forget the revision and re-mint on the next mount. - // It holds one short entry per file shown, and goes with the registry. - const mintedUnder = new WeakMap>(); - const family = Atom.family((key: string) => { - const [environmentId, resource, revision] = decodeRevisionedAssetUrlKey(JSON.parse(key)); - const resourceKey = JSON.stringify([environmentId, resource]); - const query = createUrl({ environmentId, input: { resource } }); - return Atom.transform( - query, - (get) => { - let minted = mintedUnder.get(get.registry); - if (minted === undefined) { - minted = new Map(); - mintedUnder.set(get.registry, minted); - } - const firstRead = !minted.has(resourceKey); - let pending = minted.get(resourceKey) !== revision; - const current = get.once(query); - if (pending && (firstRead || isSettled(current))) { - pending = false; - minted.set(resourceKey, revision); - if (isSettled(current)) get.refresh(query); - } - get.subscribe(query, (value) => { - get.setSelf(value); - if (!pending || !isSettled(value)) return; - pending = false; - minted.set(resourceKey, revision); - get.refresh(query); - }); - return get.once(query); - }, - { initialValueTarget: query }, - // No idle TTL: each mount rebuilds the node, so a revision read again after - // another one is compared against the record instead of a cached node. - ).pipe(Atom.setIdleTTL(0)); - }); - return (target: AssetUrlTarget & { readonly revision: string | null }) => - family(JSON.stringify([target.environmentId, target.input.resource, target.revision])); -} - const revisionedAssetUrl = makeRevisionedAssetUrlFamily(assetEnvironment.createUrl); /** diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 97da6d4ca8a5c..758d14b1aba6a 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -3,7 +3,8 @@ import { computeDpopJwkThumbprint, DpopPublicJwk, } from "@t3tools/shared/dpop"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -27,18 +28,7 @@ const DPOP_KEY_STORE_NAME = "keys"; const DPOP_KEY_ID = "relay-dpop-proof-key"; const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); -export const layer = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)), - digest: (algorithm, data) => - Effect.promise(async () => { - const input = new Uint8Array(data.length); - input.set(data); - return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer)); - }), - }), -); +export const layer = Layer.mergeAll(SecureRandom.layerWebCrypto, Digest.layerWebCrypto); function dpopError(message: string, cause?: unknown) { return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) }); @@ -150,7 +140,7 @@ export const createBrowserDpopProof = Effect.fn("dpop.createBrowserDpopProof")(f }): Effect.fn.Return< { readonly proof: string; readonly thumbprint: string }, BrowserDpopError, - Crypto.Crypto + SecureRandom.SecureRandom > { const normalizedUrl = yield* Effect.try({ try: () => new URL(input.url), @@ -158,10 +148,7 @@ export const createBrowserDpopProof = Effect.fn("dpop.createBrowserDpopProof")(f }); normalizedUrl.search = ""; normalizedUrl.hash = ""; - const jti = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), - Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), - ); + const jti = yield* SecureRandom.SecureRandom.pipe(Effect.flatMap((random) => random.uuidV4())); const proof = yield* Effect.tryPromise({ try: () => new SignJWT({ diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 8605169807e65..9192bac9b4185 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -82,7 +82,7 @@ function layerRegistry(options?: { const session: RpcSession = { client, initialConfig: Effect.never, - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 745adf17f171f..0d293d03a424e 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,6 +1,6 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayWebClientId } from "@t3tools/contracts/relay"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Semaphore from "effect/Semaphore"; @@ -16,7 +16,7 @@ import { const layerRelayDpopSigner = Layer.effect( ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const keyLoadSemaphore = yield* Semaphore.make(1); let loadedKey: BrowserDpopKey | null = null; const loadOrCreateBrowserDpopKey = keyLoadSemaphore.withPermit( @@ -60,7 +60,7 @@ const layerRelayDpopSigner = Layer.effect( ), ); return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.map((proof) => proof.proof), Effect.mapError( (error) => diff --git a/apps/web/src/components/ChatView.logic.browser.test.ts b/apps/web/src/components/ChatView.logic.browser.test.ts index e1c1f04a0c3a5..233af7115eeb3 100644 --- a/apps/web/src/components/ChatView.logic.browser.test.ts +++ b/apps/web/src/components/ChatView.logic.browser.test.ts @@ -3,20 +3,20 @@ import { userEvent } from "vite-plus/test/browser"; import { shouldRefocusComposerOnWindowFocus } from "./ChatView.logic"; it("keeps keyboard focus on a sidebar row on window return, but not a clicked row", async () => { - const tree = document.createElement("div"); - tree.setAttribute("role", "tree"); - const row = (name: string, tabIndex: number) => { + const list = document.createElement("ul"); + const row = (name: string) => { const element = document.createElement("div"); - element.setAttribute("role", "treeitem"); - element.tabIndex = tabIndex; + element.setAttribute("role", "button"); + element.tabIndex = 0; + element.dataset.sidebarRow = name; element.textContent = name; - tree.append(element); + list.append(element); return element; }; - const clicked = row("Clicked thread", -1); - const tabbed = row("Tabbed thread", 0); - document.body.append(tree); - using _tree = { [Symbol.dispose]: () => tree.remove() }; + const clicked = row("Clicked thread"); + const tabbed = row("Tabbed thread"); + document.body.append(list); + using _list = { [Symbol.dispose]: () => list.remove() }; await userEvent.click(clicked); expect(document.activeElement).toBe(clicked); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index d8b78adc3684e..4ef18af9d21e5 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -7,7 +7,6 @@ import { snapshotComposerSend, withMessageContext, deriveAvailableRevertTurnCounts, - deriveWorkspaceMutationId, } from "./ChatView.logic"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; @@ -1335,29 +1334,3 @@ describe("deriveAvailableRevertTurnCounts", () => { expect(deriveAvailableRevertTurnCounts(null).size).toBe(0); }); }); - -describe("deriveWorkspaceMutationId", () => { - const item = (id: string, type: string, status: string) => - ({ - item: { id: TurnItemId.make(id), type, status }, - }) as unknown as OrchestrationV2ProjectedTurnItem; - - it("keys on the latest finished command or file change and the latest checkpoint", () => { - expect( - deriveWorkspaceMutationId( - [ - item("edit", "file_change", "completed"), - item("reply", "assistant_message", "completed"), - item("command", "command_execution", "running"), - ], - "2026-01-01T00:00:00.000Z", - ), - ).toBe(JSON.stringify(["edit", "2026-01-01T00:00:00.000Z"])); - }); - - it("is null before anything could have touched the workspace", () => { - expect(deriveWorkspaceMutationId([item("reply", "assistant_message", "completed")], null)).toBe( - null, - ); - }); -}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 702670fd409c9..56e13d7a46f4b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1178,7 +1178,7 @@ export function shouldRefocusComposerOnWindowFocus( } return ( activeElement.closest( - '[data-workspace-area]:focus-visible, [role="treeitem"]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]', + '[data-workspace-area]:focus-visible, [data-sidebar-row]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]', ) === null ); } @@ -1633,28 +1633,3 @@ export function deriveAvailableRevertTurnCounts(projection: ThreadStructure | nu } return available; } - -/** - * Changes when the agent may have changed the workspace, so file views refresh. File tools are - * explicit; completed commands are included because a shell command can mutate the workspace - * without reporting the paths it touched. - */ -export function deriveWorkspaceMutationId( - visibleTurnItems: ReadonlyArray, - latestCheckpointCompletedAt: string | null, -) { - let itemId: string | null = null; - for (let index = visibleTurnItems.length - 1; index >= 0; index -= 1) { - const item = visibleTurnItems[index]?.item; - if (!item) continue; - if (item.type !== "command_execution" && item.type !== "file_change") continue; - if (item.status === "pending" || item.status === "running" || item.status === "waiting") { - continue; - } - itemId = item.id; - break; - } - return itemId === null && latestCheckpointCompletedAt === null - ? null - : JSON.stringify([itemId, latestCheckpointCompletedAt]); -} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index def567939ed50..fade62a7a40ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9,7 +9,6 @@ import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnounce import { deriveAvailableRevertTurnCounts, deriveThreadPendingBackgroundTasks, - deriveWorkspaceMutationId, presentThreadShellFromProjection, } from "./ChatView.logic"; import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; @@ -94,6 +93,7 @@ import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { codexFeedbackMessage, + deriveWorkspaceMutationId, isCompleteTurnItemRow, shouldShowLoadEarlierControl, type CodexFeedbackSubmission, @@ -112,6 +112,7 @@ import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; import { type SetStateAction, use, useEffect, useLayoutEffect, useRef, useState } from "react"; import { BackgroundThreadContext } from "./chat/backgroundThread"; +import { WorkspaceMutationIdContext } from "./chat/ChatMarkdownMedia"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; @@ -271,6 +272,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { useCitedHistory } from "./chat/citationProcedure"; import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ThreadTimeline } from "./chat/ThreadTimeline"; +import { NativeChildWorkDialog } from "./chat/NativeChildWorkDialog"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; @@ -3404,104 +3406,106 @@ export default function ChatView(props: ChatViewProps) { {...conversationArea} className="relative flex min-h-0 flex-1 flex-col bg-background" > - + + + {showScrollToBottom && !isThreadFindActive && ( + ) : null + } /> ) : null} {!composerMounted ? null : ( diff --git a/apps/web/src/components/CommandPalette.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx index 4787885905985..2eb5841180825 100644 --- a/apps/web/src/components/CommandPalette.composition.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -21,6 +21,7 @@ vi.mock("../hooks/useEnvironmentTheme", () => ({ useEnvironmentThemeDefinitions: vi.mock("../hooks/useSettings", async (original) => ({ ...(await original()), useClientSettings: () => DEFAULT_CLIENT_SETTINGS, + useUpdateEnvironmentSettings: () => () => undefined, })); vi.mock("../hooks/useScratchProject", () => ({ useScratchProject: () => ({ scratchEnvironmentId: () => null }), @@ -66,6 +67,10 @@ vi.mock("../state/entities", async (original) => ({ ...(await original()), useProjects: () => [], useThreadShells: () => [], + useVisibleThreadShells: () => [], + useHiddenThreadKeys: () => new Set(), + useAnyEnvironmentHiding: () => false, + useEnvironmentThreadRefs: () => [], useServerConfigs: () => new Map(), waitForProject: async () => undefined, })); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 9a1389f466109..b6eafa4bfd0e1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -134,7 +134,16 @@ import { useEnvironments, usePrimaryEnvironmentId, } from "../state/environments"; -import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; +import { + useAnyEnvironmentHiding, + useHiddenThreadKeys, + useEnvironmentThreadRefs, + useProjects, + useServerConfigs, + useVisibleThreadShells, + waitForProject, +} from "../state/entities"; +import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -405,6 +414,11 @@ function OpenCommandPaletteDialog(props: { }, ); const linkedThreadSearch = initialSearch.linkedThreadSearch; + // Rows were captured when the palette opened; drop any hidden since then. + const hiddenKeys = useHiddenThreadKeys(); + const linkedEnvironmentId = linkedThreadSearch?.linkedThreads?.environmentId ?? null; + const hidingAnywhere = useAnyEnvironmentHiding(); + const linkedThreadRefs = useEnvironmentThreadRefs(linkedEnvironmentId); const [searchState, dispatchSearch] = useReducer(reduceCommandPaletteSearchState, null, () => { const view = props.initialContext ? null : openIntentView(props.openIntent); return view @@ -487,7 +501,7 @@ function OpenCommandPaletteDialog(props: { if (activeThreadReferenceCopyTarget === null) return; await copyThreadReference(activeThreadReferenceCopyTarget); }; - const threads = useThreadShells(); + const threads = useVisibleThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providersByEnvironment = useEnvironmentProviders(); const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems(); @@ -1780,6 +1794,13 @@ function OpenCommandPaletteDialog(props: { isLinkedThreadSearch && linkedThreadSearch?.linkedThreads ? buildLinkedThreadActionItems({ ...linkedThreadSearch.linkedThreads, + threads: visibleLinkedThreads( + linkedThreadSearch.linkedThreads.threads, + linkedThreadSearch.linkedThreads.environmentId, + linkedThreadRefs, + hiddenKeys, + hidingAnywhere, + ), query: linkedThreadSearch.query, icon: , runThread: async (thread) => { diff --git a/apps/web/src/components/CommandPaletteLookups.ts b/apps/web/src/components/CommandPaletteLookups.ts index 6c9fcc629f732..3eb0ff69bf1c7 100644 --- a/apps/web/src/components/CommandPaletteLookups.ts +++ b/apps/web/src/components/CommandPaletteLookups.ts @@ -7,7 +7,7 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useClientSettings } from "../hooks/useSettings"; import { getProjectOrderKey } from "@t3tools/client-runtime/state/project-grouping"; -import { useProjects, useThreadShells } from "../state/entities"; +import { useProjects, useVisibleThreadShells } from "../state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -55,7 +55,7 @@ export const selectCommandPaletteClientSettings = (settings: ClientSettings) => export function useSortedProjectGroups(environmentLabelById: ReadonlyMap) { const projects = useProjects(); - const threads = useThreadShells(); + const threads = useVisibleThreadShells(); const projectOrder = useUiStateStore((store) => store.projectOrder); const clientSettings = useClientSettings(selectCommandPaletteClientSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx index d106b4d1069da..ddccc1c3a54e1 100644 --- a/apps/web/src/components/CommandPaletteThreadItems.tsx +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -23,7 +23,7 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry, } from "../providerInstances"; -import { useThreadShells } from "../state/entities"; +import { useVisibleThreadShells } from "../state/entities"; import { useEnvironmentProviders, useEnvironments, @@ -99,7 +99,7 @@ export function useCommandPaletteThreadItems(input: { }) { const { activeThread, threadSearchQuery } = input; const navigate = useNavigate(); - const threads = useThreadShells(); + const threads = useVisibleThreadShells(); const { projectEnvironmentLocationById } = useEnvironmentLookups(); const providerEntryByEnvironmentAndInstanceId = useProviderEntryByEnvironmentAndInstanceId(); const messageSearch = useConnectedThreadSearch(threadSearchQuery); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3927c99325f1..722aa82d604db 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { liveShellEnvironmentIdsAtom } from "../state/shell"; -import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; +import { attachSidebarTree } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; @@ -3262,6 +3262,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve onToggle={() => toggleProjectGroupPin(project)} /> ) : null} + {project && showProjectEnvironments ? ( + + ) : null} {project ? ( )} - +
{item.type === "video" ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2fcaa596a5b6c..6b1df4ca16927 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,6 +1,6 @@ import { flushSync } from "react-dom"; import { AudioPreview } from "../files/AudioPreview"; -import { page } from "vite-plus/test/browser"; +import { cdp, page } from "vite-plus/test/browser"; import { ComposerContextId, EnvironmentId, @@ -217,6 +217,45 @@ const TASK_ID = ScheduledTaskId.make("task-1"); const AUTOMATION_PROMPT = "Triage the inbox\n\nLabel every new issue and close duplicates."; describe("MessagesTimeline work rows", () => { + it("keeps the worked-for heading in place when expanded at the bottom", async () => { + const props = baseProps(); + const runId = RunId.make("completed-run"); + const container = document.createElement("div"); + container.style.cssText = "height: 600px; width: 900px; display: flex; flex-direction: column;"; + await using screen = await render( + + message(`Earlier context ${index}`, {}, `context-${index}`), + ), + message("Progress details\n\n".repeat(50), { role: "assistant", runId }, "progress"), + message("Done", { role: "assistant", runId }, "final"), + ]} + />, + { container }, + ); + const heading = screen.getByRole("button", { name: /^Worked for/ }); + await expect.element(heading).toBeVisible(); + const viewport = props.listRef.current!.getScrollableNode()!; + await vi.waitFor(() => + expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThan(2), + ); + expect(viewport.scrollTop).toBeGreaterThan(0); + const top = heading.element().getBoundingClientRect().top; + const height = viewport.scrollHeight; + using _timers = fakeTimers(); + await heading.click(); + await act(() => vi.advanceTimersByTime(1000)); + await vi.waitFor(() => { + expect(viewport.scrollHeight).toBeGreaterThan(height + 500); + expect(heading.element().getBoundingClientRect().top).toBeCloseTo(top, 0); + expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeGreaterThan( + 500, + ); + }); + }); + it.each(["delegated", "legacy", "missing"] as const)( "uses the reported task for %s completion notifications", async (source) => { @@ -526,6 +565,76 @@ describe("MessagesTimeline messages", () => { }); }); +function usageLimitEntries(resetAt: string) { + const threadId = ThreadId.make("usage-limit-thread"); + const at = DateTime.makeUnsafe(CREATED_AT); + return deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + position: 0, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("usage-limit"), + item: { + id: TurnItemId.make("usage-limit"), + threadId, + runId: RunId.make("usage-limit-run"), + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "error", + status: "failed", + failure: { + class: "usage_limit", + message: "You've hit your usage limit.", + code: null, + retryable: true, + resetAt, + }, + }, + }, + ], + optimisticMessages: [], + }); +} + +describe("MessagesTimeline usage limit notice", () => { + it("labels the retry time with the viewer's timezone", async () => { + await using screen = await renderTimeline(usageLimitEntries("2026-03-17T19:30:00.000Z")); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + expect(screen.text()).toMatch(/Usage limit reached\. Retry after .+\./); + expect(screen.text()).toContain(`(${timeZone})`); + }); + + it("keeps the retry time in the named zone after the viewer's timezone changes", async () => { + const resetAt = "2026-07-01T19:30:00.000Z"; + const session = cdp(); + await session.send("Emulation.setTimezoneOverride", { timezoneId: "Europe/Madrid" }); + try { + { + await using _madrid = await renderTimeline(usageLimitEntries(resetAt)); + } + await session.send("Emulation.setTimezoneOverride", { timezoneId: "America/New_York" }); + await using screen = await renderTimeline(usageLimitEntries(resetAt)); + const newYorkTime = new Date(resetAt).toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + timeZone: "America/New_York", + }); + expect(screen.text()).toContain(`${newYorkTime} (America/New_York)`); + } finally { + await session.send("Emulation.setTimezoneOverride", { timezoneId: "" }); + } + }); +}); + const CITATION_REQUEST = { key: "activation-1", citation: { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e32a7eb485fc6..4c7eba620b5d1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1528,60 +1528,6 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); - it("keeps the latest finished reply's timestamp while a command is still running", () => { - const reply = (streaming: boolean) => ({ - id: "assistant-reply-entry", - kind: "message" as const, - createdAt: "2026-01-01T00:00:10Z", - message: { - id: "assistant-reply" as never, - role: "assistant" as const, - text: "Training finished, checking memory next.", - runId: "turn-1" as never, - createdAt: "2026-01-01T00:00:10Z", - updatedAt: "2026-01-01T00:00:12Z", - streaming, - }, - }); - const rowsWith = (streaming: boolean) => - deriveMessagesTimelineRows({ - timelineEntries: [ - reply(streaming), - { - id: "command-entry", - kind: "work", - createdAt: "2026-01-01T00:00:15Z", - entry: { - id: "command", - createdAt: "2026-01-01T00:00:15Z", - runId: "turn-1" as never, - label: "Running command", - command: "nvidia-smi", - tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, - }, - }, - ], - latestRun: { - runId: "turn-1" as never, - status: "running", - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaries: [], - supportsConversationRollback: false, - }).find((row) => row.id === "assistant-reply-entry"); - - expect(rowsWith(false)).toMatchObject({ - showAssistantMeta: false, - showAssistantTimestamp: true, - showAssistantCopyButton: false, - }); - expect(rowsWith(true)).toMatchObject({ showAssistantTimestamp: false }); - }); - it("does not show Thinking once the open run's provider turn has ended", () => { const input = { timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8a4a7552901a2..2fe811db4d18f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -591,8 +591,6 @@ type MessagesTimelineRowContent = message: ChatMessage; projectedItem?: OrchestrationV2ProjectedTurnItem; showAssistantMeta: boolean; - /** The latest finished reply of a still-running response shows only its timestamp. */ - showAssistantTimestamp: boolean; showAssistantCopyButton: boolean; assistantCopyStreaming: boolean; assistantTurnDiffSummary?: TurnDiffSummary | undefined; @@ -2029,10 +2027,10 @@ export function deriveMessagesTimelineRows(input: { timelineEntry.message.role === "assistant" && runIdIsActiveResponse(timelineEntry.message.runId); - const isTerminalAssistantMessage = + const showAssistantMeta = timelineEntry.message.role === "assistant" && - terminalAssistantMessageIds.has(timelineEntry.message.id); - const showAssistantMeta = isTerminalAssistantMessage && !assistantResponseStillInProgress; + terminalAssistantMessageIds.has(timelineEntry.message.id) && + !assistantResponseStillInProgress; nextRows.push({ kind: "message", @@ -2043,10 +2041,6 @@ export function deriveMessagesTimelineRows(input: { ? {} : { projectedItem: timelineEntry.projectedItem }), showAssistantMeta, - showAssistantTimestamp: - isTerminalAssistantMessage && - assistantResponseStillInProgress && - !timelineEntry.message.streaming, showAssistantCopyButton: showAssistantMeta, assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress, assistantTurnDiffSummary: @@ -2510,7 +2504,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.message === bm.message && a.projectedItem === bm.projectedItem && a.showAssistantMeta === bm.showAssistantMeta && - a.showAssistantTimestamp === bm.showAssistantTimestamp && a.showAssistantCopyButton === bm.showAssistantCopyButton && a.assistantCopyStreaming === bm.assistantCopyStreaming && a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a2a186c5cb8ec..92389f2feced3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -470,13 +470,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ const normalizedFindQuery = findQuery.trim(); const findActive = findExpanded; - const disclosurePinsEndRef = useRef(false); - const pinDisclosureToEnd = () => { - if (!disclosurePinsEndRef.current) return; - const element = listRef.current?.getScrollableNode(); - if (element) element.scrollTop = element.scrollHeight; - }; - const scheduleDisclosureSettle = () => { if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); @@ -485,10 +478,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } disclosureSettleFrameRef.current = requestAnimationFrame(() => { - pinDisclosureToEnd(); disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { - pinDisclosureToEnd(); - disclosurePinsEndRef.current = false; disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); disclosureSettleFrameRef.current = null; @@ -499,13 +489,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string) => { disclosureAnchorKeyRef.current = anchorKey; - disclosurePinsEndRef.current = resolveTimelineIsAtEnd(listRef.current?.getState()) === true; setDisclosureToggleSettling(true); scheduleDisclosureSettle(); }; const shouldRestoreVisibleContentPosition = (row: MessagesTimelineRow) => { - if (disclosurePinsEndRef.current) return false; const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || row.id === disclosureAnchorKey; }; @@ -959,8 +947,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; const handleItemSizeChanged = () => { - if (disclosurePinsEndRef.current) { - queueMicrotask(pinDisclosureToEnd); + if (disclosureAnchorKeyRef.current !== null) { scheduleDisclosureSettle(); } scheduleMinimapRefresh(); diff --git a/apps/web/src/components/chat/NativeChildWorkDialog.tsx b/apps/web/src/components/chat/NativeChildWorkDialog.tsx new file mode 100644 index 0000000000000..7695a0e38c62d --- /dev/null +++ b/apps/web/src/components/chat/NativeChildWorkDialog.tsx @@ -0,0 +1,131 @@ +import type { + EnvironmentId, + OrchestrationV2GetNativeChildWorkResult, + OrchestrationV2NativeChildStep, + OrchestrationV2NativeChildWork, + ThreadId, +} from "@t3tools/contracts"; +import { ListTreeIcon } from "lucide-react"; +import { useState } from "react"; + +import { orchestrationEnvironment } from "../../state/orchestration"; +import { useEnvironmentQuery } from "../../state/query"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; + +const stepLabel = (type: string) => { + const words = type.toLowerCase().replaceAll("_", " "); + return words.charAt(0).toUpperCase() + words.slice(1); +}; + +/** Read-only view of what a provider-native subagent's children did. Loads only while open. */ +export function NativeChildWorkDialog(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + Subagent work + Read only. These subagents cannot take messages. + + {open ? : null} + + + ); +} + +function NativeChildWorkContent(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const query = useEnvironmentQuery( + orchestrationEnvironment.nativeChildWork({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }), + ); + const children = query.data?.children; + return ( + <> + + {query.error !== null ? ( +

{query.error}

+ ) : children === undefined ? ( +

Loading…

+ ) : children.length === 0 ? ( +

+ The provider has no record of which subagents this call started. +

+ ) : ( + children.map((child) => ) + )} +
+ + + + + ); +} + +function NativeChild(props: { readonly child: OrchestrationV2NativeChildWork }) { + const { child } = props; + return ( +
+

{child.role || "Subagent"}

+

+ {child.prompt} +

+ {child.truncated ? ( +

Earlier steps are not shown.

+ ) : null} + {child.steps === null ? ( +

No transcript yet.

+ ) : ( +
    + {child.steps.map((step) => ( + + ))} +
+ )} +
+ ); +} + +function NativeChildStep(props: { readonly step: OrchestrationV2NativeChildStep }) { + const { step } = props; + return ( +
  • + + {stepLabel(step.type)} + {step.status !== null && step.status !== "DONE" ? ` · ${stepLabel(step.status)}` : ""} + + {step.toolCalls.length === 0 ? null : ( + + {step.toolCalls.map((call) => `${call.name} ${call.input}`).join("\n")} + + )} + {step.text === null ? null : ( +
    +          {step.text}
    +        
    + )} +
  • + ); +} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6c597a14cdf5c..54c3f2e850b00 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -422,7 +422,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }} initialFocus={searchInputRef} align={props.popupAlign ?? "start"} - collisionAvoidance={docked ? { align: "shift", fallbackAxisSide: "none" } : undefined} + // Docked, the popup opens above the composer and slides over it when that space is short, + // so a short window gets its full height (Floating UI sizes to the viewport) instead of a sliver. + {...(docked + ? { side: "top" as const, collisionAvoidance: { side: "shift", align: "shift" } as const } + : {})} className="before:hidden" padding="none" touchBackdrop diff --git a/apps/web/src/components/chat/ProviderSubagentBar.tsx b/apps/web/src/components/chat/ProviderSubagentBar.tsx index 6aa8799c70b7e..1b74e43284f23 100644 --- a/apps/web/src/components/chat/ProviderSubagentBar.tsx +++ b/apps/web/src/components/chat/ProviderSubagentBar.tsx @@ -4,6 +4,7 @@ import { } from "@t3tools/client-runtime/state/thread-execution"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; import { ArrowUpLeftIcon } from "lucide-react"; +import type { ReactNode } from "react"; import { tickTextContent } from "~/lib/tickTextContent"; import type { ProviderInstanceEntry } from "../../providerInstances"; @@ -17,6 +18,7 @@ export function ProviderSubagentBar(props: { readonly effortLabel: string | null; readonly status: ProviderSubagentStatus | null; readonly onOpenParent: (() => void) | null; + readonly actions?: ReactNode; }) { const { status } = props; const live = status !== null && isOrchestrationV2WorkActive(status.status); @@ -64,6 +66,7 @@ export function ProviderSubagentBar(props: { Runs on its own + {props.actions} {props.onOpenParent ? (
    diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 74f72af23012a..d0379af03ad6c 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -38,6 +38,7 @@ import { } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; +import { ModelSwitchRow } from "./V2LifecycleRow"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { AttachmentImage } from "../media/AttachmentImage"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; @@ -247,6 +248,20 @@ function UserVideoAttachment({ } export function UserMessageRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const item = row.projectedItem?.item; + const modelSwitch = item?.type === "user_message" ? item.modelSwitch : undefined; + return ( + <> + {modelSwitch ? ( + + ) : null} + + + ); +} + +function UserMessageContent({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); return ctx.workflowOpening?.messageId === row.message.id ? ( ; } +/** A same-harness model or effort change. Shows only what changed. */ +export function ModelSwitchRow(props: { + readonly environmentId: EnvironmentId; + readonly modelSwitch: OrchestrationV2ModelSwitch; +}) { + const providers = + useAtomValue(serverEnvironment.providersValueAtom(props.environmentId)) ?? + EMPTY_SERVER_PROVIDERS; + const change = resolveModelSwitchChange(props.modelSwitch, providers); + if (change === null) return null; + const describe = (selection: ModelSelection, effort: string | undefined) => { + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === selection.model); + const modelName = model ? getTriggerDisplayModelName(model, provider?.driver) : selection.model; + return [change.modelChanged ? modelName : null, effort].filter(Boolean).join(" "); + }; + const { from, to } = props.modelSwitch; + return ( + + ); +} + export function HandoffRow(props: { readonly environmentId: EnvironmentId; readonly endpoints: HandoffEndpoints; diff --git a/apps/web/src/components/chat/folderDrop.test.ts b/apps/web/src/components/chat/folderDrop.test.ts new file mode 100644 index 0000000000000..6c42bf42cb6dd --- /dev/null +++ b/apps/web/src/components/chat/folderDrop.test.ts @@ -0,0 +1,82 @@ +import { + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { folderDropTarget } from "./folderDrop"; + +const environmentId = EnvironmentId.make("saved-local"); +const base = { environmentId, label: "server" }; +const savedServer = (httpBaseUrl: string, connectionId = "saved-1") => ({ + target: new BearerConnectionTarget({ ...base, connectionId }), + httpBaseUrl, +}); +const thinClient = { + localEnvironmentDisabled: true, + environmentId, + primaryEnvironmentId: null, + isDesktopRenderer: true, +}; + +describe("folderDropTarget", () => { + it.each([ + [ + "the bundled primary", + { + ...thinClient, + localEnvironmentDisabled: false, + primaryEnvironmentId: environmentId, + connection: null, + }, + "local", + ], + [ + "a saved loopback server on desktop", + { ...thinClient, connection: savedServer("http://127.0.0.1:3773") }, + "local", + ], + [ + "a saved LAN server", + { ...thinClient, connection: savedServer("http://192.168.1.20:3773") }, + "remote", + ], + [ + "a saved loopback server in a browser", + { ...thinClient, isDesktopRenderer: false, connection: savedServer("http://localhost:3773") }, + "remote", + ], + [ + "a desktop WSL backend", + { ...thinClient, connection: savedServer("http://127.0.0.1:3774", "local:wsl-ubuntu") }, + "remote", + ], + [ + "an SSH forward on loopback", + { + ...thinClient, + connection: { + target: new SshConnectionTarget({ ...base, connectionId: "ssh-1" }), + httpBaseUrl: "http://127.0.0.1:41000", + }, + }, + "remote", + ], + [ + "a relay connection", + { + ...thinClient, + connection: { + target: new RelayConnectionTarget(base), + httpBaseUrl: "https://relay.t3.codes", + }, + }, + "remote", + ], + ["an unprepared connection", { ...thinClient, connection: null }, "remote"], + ] as const)("targets %s", (_name, input, expected) => { + expect(folderDropTarget(input)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/chat/folderDrop.ts b/apps/web/src/components/chat/folderDrop.ts index 8f462394e8fac..df4089589aa2d 100644 --- a/apps/web/src/components/chat/folderDrop.ts +++ b/apps/web/src/components/chat/folderDrop.ts @@ -1,18 +1,43 @@ +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { isLoopbackHostname } from "~/environments/primary/target"; + export function folderDropTarget(input: { localEnvironmentDisabled: boolean; environmentId: EnvironmentId; primaryEnvironmentId: EnvironmentId | null; + connection: Pick | null; + isDesktopRenderer: boolean; }): "local" | "remote" { if ( - input.localEnvironmentDisabled || - input.primaryEnvironmentId === null || - input.environmentId !== input.primaryEnvironmentId + !input.localEnvironmentDisabled && + input.primaryEnvironmentId !== null && + input.environmentId === input.primaryEnvironmentId ) { - return "remote"; + return "local"; + } + return input.isDesktopRenderer && + input.connection !== null && + isSavedLoopbackConnection(input.connection) + ? "local" + : "remote"; +} + +// A direct saved connection to loopback shares the desktop's filesystem; WSL backends and SSH forwards do not. +function isSavedLoopbackConnection({ + target, + httpBaseUrl, +}: Pick): boolean { + if (target._tag !== "BearerConnectionTarget" || isDesktopLocalConnectionTarget(target)) { + return false; + } + try { + return isLoopbackHostname(new URL(httpBaseUrl).hostname); + } catch { + return false; } - return "local"; } export function resolveDroppedFolderPath( diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts index a72813d8f755b..776cd39096a8a 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.test.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -15,7 +15,6 @@ describe("deriveTimelineMinimapItems", () => { id: "image-only", createdAt: at, showAssistantMeta: false, - showAssistantTimestamp: false, showAssistantCopyButton: false, assistantCopyStreaming: false, message: { diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 6ef19c2c0f460..239098ae02975 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -29,7 +29,7 @@ import { getProviderSlashCommandsForSlashMenu, } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; -import { useThreadShells } from "~/state/entities"; +import { useVisibleThreadShells } from "~/state/entities"; import { useEnvironmentIdentities, useEnvironmentMachines } from "~/state/environments"; import { offersLinkedEnvironmentsAtom } from "~/state/peerLinks"; import { @@ -119,7 +119,7 @@ export function useComposerMenuItems({ const settledPullRequestTextQuery = pullRequestTextQuery === debouncedPullRequestTextQuery ? pullRequestTextQuery : null; const isPathTrigger = composerTriggerKind === "path"; - const environmentThreadShells = useThreadShells(isPathTrigger); + const environmentThreadShells = useVisibleThreadShells(isPathTrigger); // Mounts older settled threads matching the mention, which shells may leave out. useThreadSearch(isPathTrigger ? [environmentId] : [], pathTriggerQuery, { titlesOnly: true }); // The user's machines feed `@` machine matches; the catalog summary is cheap and stable. diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 95305cca45bb9..28c9d0a567cd1 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2528,6 +2528,7 @@ it("keeps workspace PDF scroll and zoom after reloads", async () => { const { preview, urls } = fixture; await using screen = await render(preview(urls.original, "one")); await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); + using workers = vi.spyOn(window, "Worker"); await screen.getByRole("button", { name: "Next page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); await screen.getByRole("button", { name: "Next page", exact: true }).click(); @@ -2566,6 +2567,7 @@ it("keeps workspace PDF scroll and zoom after reloads", async () => { expect(region().scrollLeft).toBe(0); expect(offset(2)).toBeCloseTo(fitOffset, 0); }); + expect(workers).not.toHaveBeenCalled(); }); it("keeps first-page PDF zoom after reload and resets a different document", async () => { @@ -2573,6 +2575,7 @@ it("keeps first-page PDF zoom after reload and resets a different document", asy const { preview, urls } = fixture; await using screen = await render(preview(urls.original, "one")); await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); + using workers = vi.spyOn(window, "Worker"); const { region } = pdfScroll(screen); await screen.getByRole("button", { name: "Next page", exact: true }).click(); await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); @@ -2592,6 +2595,7 @@ it("keeps first-page PDF zoom after reload and resets a different document", asy expect(region().scrollTop).toBe(0); expect(region().scrollLeft).toBe(0); }); + expect(workers).not.toHaveBeenCalled(); }); it("clamps workspace PDF scroll when pages shrink or disappear", async () => { diff --git a/apps/web/src/components/files/fileSaveSessions.browser.test.tsx b/apps/web/src/components/files/fileSaveSessions.browser.test.tsx index aff76479b7410..70456e2d92bb7 100644 --- a/apps/web/src/components/files/fileSaveSessions.browser.test.tsx +++ b/apps/web/src/components/files/fileSaveSessions.browser.test.tsx @@ -1,5 +1,5 @@ import { render, fakeTimers } from "../../../test/render"; -import { EnvironmentId } from "@t3tools/contracts"; +import { type AuthSessionState, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { StrictMode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -12,10 +12,18 @@ vi.mock(import("@t3tools/client-runtime/state/runtime"), async (original) => ({ ...(await original()), runAtomCommand: runAtomCommand as never, })); -vi.mock(import("~/state/session"), async (original) => ({ - ...(await original()), - readEnvironmentScope: () => true, -})); +// The real session atom boots the connection runtime, whose endless discovery poll +// would keep vi.runAllTimersAsync from ever draining once its timers are faked. +vi.mock(import("~/state/session"), async (original) => { + const actual = await original(); + const reactivity = await import("effect/reactivity"); + const signedOut = reactivity.Atom.make(reactivity.AsyncResult.initial()); + return { + ...actual, + environmentSession: { ...actual.environmentSession, sessionStateAtom: () => signedOut }, + readEnvironmentScope: () => true, + }; +}); vi.mock(import("./projectFilesQueryState"), async (original) => ({ ...(await original()), confirmProjectFileQueryData: () => true, diff --git a/apps/web/src/components/files/pdfDocument.ts b/apps/web/src/components/files/pdfDocument.ts index 2cc5493718b4c..32e8aefbe9028 100644 --- a/apps/web/src/components/files/pdfDocument.ts +++ b/apps/web/src/components/files/pdfDocument.ts @@ -1,4 +1,4 @@ -import { getDocument, GlobalWorkerOptions } from "pdfjs-dist"; +import { getDocument, GlobalWorkerOptions, PDFWorker } from "pdfjs-dist"; import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import fallbackUrl from "pdfjs-dist/wasm/openjpeg_nowasm_fallback.js?url"; @@ -31,8 +31,13 @@ export class BinaryDataFactory { } } +// Without a shared worker, pdf.js starts a worker and recompiles its 1.3 MB script for every document. +let worker: PDFWorker | undefined; + export function openPdf(bytes: ArrayBuffer) { + worker ??= new PDFWorker(); return getDocument({ + worker, data: new Uint8Array(bytes), useSystemFonts: true, useWorkerFetch: false, diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index f45ba1e7abc78..72c003f717a69 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -113,9 +113,11 @@ export function useMediaActions(source: MediaActionSource) { export function MediaActions({ source, children, + showTooltip = true, }: { source: MediaActionSource; children: ReactElement; + showTooltip?: boolean; }) { const { save, copyImage, canReadMedia, assertCanReadMedia } = useMediaActions(source); const [tooltipOpen, setTooltipOpen] = useState(false); @@ -204,7 +206,7 @@ export function MediaActions({ }; return ( - + { expect(await readMediaPng("https://example.com/image.png")).toEqual(png); expect(fetch).toHaveBeenCalledWith("https://example.com/image.png"); }); + +it("reads media the renderer cannot fetch through the desktop main process", async () => { + using fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); + const readMedia = vi.fn().mockResolvedValue({ + status: 200, + mimeType: "image/png", + data: new Uint8Array(PNG_BYTES), + }); + vi.stubGlobal("window", { desktopBridge: { readMedia } }); + try { + const png = await readMediaPng("https://example.com/image.png"); + + expect(fetch).toHaveBeenCalledOnce(); + expect(readMedia).toHaveBeenCalledWith("https://example.com/image.png"); + expect([...new Uint8Array(await png.arrayBuffer())]).toEqual(PNG_BYTES); + } finally { + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/components/media/mediaContent.ts b/apps/web/src/components/media/mediaContent.ts index 4b1edab6a3d45..a5a4cec61143a 100644 --- a/apps/web/src/components/media/mediaContent.ts +++ b/apps/web/src/components/media/mediaContent.ts @@ -40,19 +40,28 @@ function readDataUrlBlob(src: string): Blob { } } +async function fetchMediaBlob(src: string): Promise { + const response = await fetch(src).catch(() => null); + if (response?.ok) return response.blob(); + // An can show media that fetch() may not read (CORS, CSP, cookies); desktop reads it natively. + const desktop = typeof window === "undefined" ? undefined : window.desktopBridge; + const native = + desktop && /^https?:/i.test(src) ? await desktop.readMedia(src).catch(() => null) : null; + if (native && native.status >= 200 && native.status < 300) { + return new Blob([new Uint8Array(native.data)], { type: native.mimeType }); + } + const status = native?.status ?? response?.status; + if (status !== undefined) throw new Error(`The file could not be fetched (HTTP ${status}).`); + throw new Error( + desktop + ? "The file could not be fetched. Check the connection and try again." + : "The file could not be fetched. The host may block browser access (CORS), or the connection may be unavailable.", + ); +} + async function readMediaBlob(src: string): Promise { if (/^data:/i.test(src)) return readDataUrlBlob(src); - let response: Response; - try { - response = await fetch(src); - } catch (cause) { - throw new Error( - "The file could not be fetched. The host may block browser access (CORS), or the connection may be unavailable.", - { cause }, - ); - } - if (!response.ok) throw new Error(`The file could not be fetched (HTTP ${response.status}).`); - const blob = await response.blob(); + const blob = await fetchMediaBlob(src); if (blob.type.split(";", 1)[0] === "text/html") { throw new Error("This link returned a web page instead of media. Open the original URL."); } diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts index f6ad67432acdd..dee29a16cf527 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.test.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -230,11 +230,14 @@ describe("revealServerTabs", () => { expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).not.toBe( browserSurfaceId, ); + const userActionRevision = useRightPanelStore.getState().getUserActionRevision(ref); revealServerTabs(ref, [tab("tab_a", true)], { autoShowFloatingPreview: true }); expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).toBe( browserSurfaceId, ); expect(floatingTabId()).toBeNull(); + // A user action moves keyboard focus into the panel; an agent reveal must not. + expect(useRightPanelStore.getState().getUserActionRevision(ref)).toBe(userActionRevision); }); }); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts index 8273258d51e54..8255cc48a551d 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -49,7 +49,7 @@ export function revealServerTabs( (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, ); if (surface && force) { - useRightPanelStore.getState().activateSurface(threadRef, surface.id); + useRightPanelStore.getState().revealSurface(threadRef, surface.id); } else if (!surface) { usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(session.tabId)); } diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 74016db558468..b76fcff9ace06 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -177,6 +177,76 @@ function parseSlice(slice: DiffSlice, cacheKey: string, ignoreWhitespace: boolea return parsed; } +type DiffModel = { + readonly parsedSlices: ReadonlyArray; + readonly loadedFiles: ReadonlyArray["files"][number]>; + readonly sourceFileStats: ReadonlyMap>; +}; +const diffModels = new WeakMap, Map>(); +const patchHashes = new WeakMap(); + +// Hashing, ordering and counting every file of a huge pull request costs a second, and the tab +// renders far more often than its slices change, so the model is kept per slice list. +function diffModel( + slices: ReadonlyArray, + keyPrefix: string, + ignoreWhitespace: boolean, +): DiffModel { + let models = diffModels.get(slices); + if (models === undefined) diffModels.set(slices, (models = new Map())); + const cached = models.get(keyPrefix); + if (cached !== undefined) return cached; + const parsedSlices = slices.map((slice) => { + let hash = patchHashes.get(slice); + if (hash === undefined) patchHashes.set(slice, (hash = fnv1a32(slice.patch))); + // The cache key carries the theme, which the tokenizer caches against, and the patch's + // hash: a refreshed page reuses its cursor, so position alone would return a stale parse. + return parseSlice(slice, `${keyPrefix}:${slice.cursor ?? "first"}:${hash}`, ignoreWhitespace); + }); + const model: DiffModel = { + parsedSlices, + loadedFiles: orderDiffFilesLikeTree( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + ), + ), + sourceFileStats: new Map( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" + ? parsed.files.map( + (file, index) => + [ + buildFileDiffRenderKey(file), + getDiffLineStat([parsed.sourceFiles[index] ?? file]), + ] as const, + ) + : [], + ), + ), + }; + models.set(keyPrefix, model); + return model; +} + +const threadsByPathCache = new WeakMap< + ReadonlyArray, + ReadonlyMap> +>(); + +function reviewThreadsByPath(threads: ReadonlyArray) { + let byPath = threadsByPathCache.get(threads); + if (byPath === undefined) { + const grouped = new Map(); + for (const thread of threads) { + const list = grouped.get(thread.path); + if (list) list.push(thread); + else grouped.set(thread.path, [thread]); + } + threadsByPathCache.set(threads, (byPath = grouped)); + } + return byPath; +} + interface FileViewedToggleState { readonly enabled: boolean; readonly isViewed: (path: string) => boolean; @@ -492,20 +562,10 @@ function PullRequestCodeTab({ // Agent context is local, so it does not require host comment permission. const canSelectLines = commit === null && (review.inlineComment || onAddToAgentSelection !== undefined); - // The cache key carries the theme, which is what the tokenizer caches against, so a theme - // change is still a fresh parse. The patch's own hash is part of it too: a refreshed page - // reuses its cursor, and a key of position alone would hand back the parse it replaced. - const parsedSlices = loadedSlices.map((slice) => - parseSlice( - slice, - `pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`, - ignoreWhitespace, - ), - ); - const loadedFiles = orderDiffFilesLikeTree( - parsedSlices.flatMap((parsed) => - parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], - ), + const { parsedSlices, loadedFiles, sourceFileStats } = diffModel( + loadedSlices, + `pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}`, + ignoreWhitespace, ); const testFileCount = loadedFiles.filter((file) => isTestChangedFile(resolveFileDiffPath(file)), @@ -524,33 +584,16 @@ function PullRequestCodeTab({ slice.omittedFileStats.map((file) => [file.path, file] as const), ), ); - const sourceFileStats = new Map( - parsedSlices.flatMap((parsed) => - parsed?.kind === "files" - ? parsed.files.map( - (file, index) => - [ - buildFileDiffRenderKey(file), - getDiffLineStat([parsed.sourceFiles[index] ?? file]), - ] as const, - ) - : [], - ), - ); const fileTreeEntries = diffFileTreeEntries(files); + const threadsByPath = reviewThreadsByPath(detail.reviewThreads); // File membership does not guarantee a rendered hunk for the thread. const placedThreadIds = (() => { const placed = new Set(); // Commit diffs use different line coordinates; list conversations separately. if (commit !== null) return placed; for (const file of files) { - const path = resolveFileDiffPath(file); - for (const thread of detail.reviewThreads) { - if ( - thread.path === path && - thread.line !== null && - isLineInFileDiff(file, thread.side, thread.line) - ) { + for (const thread of threadsByPath.get(resolveFileDiffPath(file)) ?? []) { + if (thread.line !== null && isLineInFileDiff(file, thread.side, thread.line)) { placed.add(thread.id); } } @@ -577,9 +620,8 @@ function PullRequestCodeTab({ return created; }; - for (const thread of detail.reviewThreads) { - if (thread.path !== path || thread.line === null) continue; - if (!placedThreadIds.has(thread.id)) continue; + for (const thread of threadsByPath.get(path) ?? []) { + if (thread.line === null || !placedThreadIds.has(thread.id)) continue; groupAt(thread.side, thread.line).threads.push(thread); } if (commit === null) { diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index 8750a35660ae9..a5b9ce5a3c24c 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -1,5 +1,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { CheckIcon, MessageSquareIcon } from "lucide-react"; @@ -9,8 +11,16 @@ import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; -import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; +import { + useAnyEnvironmentHiding, + useEnvironmentThreadRefs, + useProjects, + useServerConfigs, + useThreadShell, + useVisibleThreadShells, +} from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; +import { environmentThreadShells } from "~/state/threads"; import { useEnvironmentQuery } from "~/state/query"; import { useThreadSearch } from "~/state/queries"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -58,6 +68,9 @@ function EnabledPullRequestThreadLinks({ const currentThreadRef = thread === null ? null : threadRef; const linking = usePullRequestLinking(environmentId); const linkedHere = linking.isLinked(thread, url); + const hiddenKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom); + const hidingAnywhere = useAnyEnvironmentHiding(); + const loadedThreadRefs = useEnvironmentThreadRefs(environmentId); const relations = useEnvironmentQuery( linking.mode === "multiple" && display !== "menu-item" ? pullRequestEnvironment.linkedThreads({ @@ -104,8 +117,13 @@ function EnabledPullRequestThreadLinks({ }; if (parsed === null || (!linkedHere && !linking.canLink(url))) return null; - const linkedThreads = - linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : []; + const linkedThreads = visibleLinkedThreads( + linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : [], + environmentId, + loadedThreadRefs, + hiddenKeys, + hidingAnywhere, + ); const linkedThreadsLabel = linkedThreads.length > 0 ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}` @@ -188,7 +206,7 @@ function ThreadPicker({ pending: boolean; onSelect: (threadId: ThreadId) => void; }) { - const threads = useThreadShells(); + const threads = useVisibleThreadShells(); const linking = usePullRequestLinking(environmentId); const projects = useProjects(); const [query, setQuery] = useState(""); diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.browser.test.tsx b/apps/web/src/components/pullRequest/usePullRequestActions.browser.test.tsx new file mode 100644 index 0000000000000..48e1f7412702a --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestActions.browser.test.tsx @@ -0,0 +1,74 @@ +import { EnvironmentId, ProjectId, type PullRequestRef } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; +import { usePullRequestActionRunner } from "./usePullRequestActions"; + +const state = vi.hoisted(() => ({ runAction: vi.fn() })); +vi.mock(import("~/state/use-atom-command"), () => ({ + useAtomCommand: (() => state.runAction) as never, +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function MergeButton({ reference }: { reference: PullRequestRef }) { + const { actionPending, perform } = usePullRequestActionRunner({ + environmentId: EnvironmentId.make("environment"), + reference, + terminology: sourceControlClients.get(undefined).changeRequest, + }); + return ( + + ); +} + +it("keeps pending merges with their pull request when navigating between threads", async () => { + const first: PullRequestRef = { + projectId: ProjectId.make("project"), + repository: "owner/repo", + number: 1, + }; + const second = { ...first, number: 2 }; + const firstResult = deferred<{ _tag: "Success"; value: void }>(); + const secondResult = deferred<{ _tag: "Success"; value: void }>(); + state.runAction + .mockReset() + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(secondResult.promise); + await using screen = await render(); + const button = screen.getByRole("button"); + + await userEvent.click(button); + await expect.element(button).toHaveTextContent("Merging..."); + await screen.rerender(); + await expect.element(button).toHaveTextContent("Merge"); + await expect.element(button).toBeEnabled(); + await userEvent.click(button); + expect(state.runAction).toHaveBeenCalledTimes(2); + + await screen.rerender(); + await expect.element(button).toHaveTextContent("Merging..."); + await expect.element(button).toBeDisabled(); + await act(async () => { + firstResult.resolve({ _tag: "Success", value: undefined }); + await firstResult.promise; + }); + await expect.element(button).toHaveTextContent("Merge"); + await screen.rerender(); + await expect.element(button).toHaveTextContent("Merging..."); + await act(async () => { + secondResult.resolve({ _tag: "Success", value: undefined }); + await secondResult.promise; + }); + await expect.element(button).toHaveTextContent("Merge"); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index d2d2e39ac2e34..82ce86ab8f429 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -140,13 +140,23 @@ export function usePullRequestActionRunner({ const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false, }); - const [actionPending, setActionPending] = useState(false); - const pendingRef = useRef(false); + const pendingRef = useRef(new Set()); + const [pendingKeys, setPendingKeys] = useState>(() => new Set()); + const actionKey = + reference === null + ? null + : JSON.stringify([ + environmentId, + reference.host ?? null, + reference.repository, + reference.number, + ]); + const actionPending = actionKey !== null && pendingKeys.has(actionKey); const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => { - if (pendingRef.current || reference === null) return; - pendingRef.current = true; - setActionPending(true); + if (actionKey === null || reference === null || pendingRef.current.has(actionKey)) return; + pendingRef.current.add(actionKey); + setPendingKeys(new Set(pendingRef.current)); const result = await runAction({ environmentId, input: { @@ -155,8 +165,8 @@ export function usePullRequestActionRunner({ ...(method ? { mergeMethod: method } : resolveMergeMethod ? { resolveMergeMethod } : {}), }, }); - pendingRef.current = false; - setActionPending(false); + pendingRef.current.delete(actionKey); + setPendingKeys(new Set(pendingRef.current)); if (result._tag === "Failure") { toastManager.add({ type: "error", diff --git a/apps/web/src/components/settings/AuthorizedClientsSettings.tsx b/apps/web/src/components/settings/AuthorizedClientsSettings.tsx index c63c163592c6f..61fa056cff67b 100644 --- a/apps/web/src/components/settings/AuthorizedClientsSettings.tsx +++ b/apps/web/src/components/settings/AuthorizedClientsSettings.tsx @@ -876,8 +876,8 @@ export const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow {isDefault ? ( ) : null} -
    -
    +
    +

    {endpoint.label}

    @@ -894,14 +894,14 @@ export const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow ) : null} {!isAvailable ? ( - + Setup required ) : null}
    -
    +
    {isDefault ? ( - + Default ) : null} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index a67e914182e0e..379be839e2e28 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -5,7 +5,7 @@ import { useEnvironmentsWithScope } from "../../state/session"; import { mapAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { planProjectRemoval } from "@t3tools/client-runtime/state/projects"; -import { type EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { checkProjectAccess, reportProjectFailure, @@ -18,8 +18,12 @@ import { lazy, Suspense, useRef, useState, type ReactNode } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; +import { persistClientSettingsUpdate, useClientSettings } from "../../hooks/useSettings"; import { readLocalApi } from "../../localApi"; +import { derivePhysicalProjectKey } from "@t3tools/client-runtime/state/project-grouping"; import { + PROJECT_GROUPING_MODE_LABELS, + withProjectGroupingOverride, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../../sidebarProjectGrouping"; @@ -30,8 +34,9 @@ import { ProjectFavicon } from "../ProjectFavicon"; import { Alert, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; -import { toastManager } from "../ui/toast"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingResetButton, @@ -61,6 +66,78 @@ const ProjectIconPickerDialog = lazy(() => })), ); +function isGroupingSelection(value: unknown): value is SidebarProjectGroupingMode | "inherit" { + return ( + value === "inherit" || + value === "repository" || + value === "repository_path" || + value === "separate" + ); +} + +// Grouping overrides are client settings, so they stay editable without project write access. +function CheckoutGroupingSelect({ member }: { member: SidebarProjectGroupMember }) { + const overrideKey = derivePhysicalProjectKey(member); + const globalMode = useClientSettings((settings) => settings.sidebarProjectGroupingMode); + const selection = useClientSettings( + (settings) => settings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit", + ); + return ( + + ); +} + export type ProjectSettingsCategory = "general" | "source-control" | "project"; interface ProjectSelection { @@ -334,19 +411,32 @@ function ProjectDetail({ if (members) members.push(member); else checkoutsByEnvironment.set(member.environmentId, [member]); } - const checkoutRemoveButton = (member: SidebarProjectGroupMember) => ( - + // A lone checkout is removed from the Danger section, but it still needs its grouping + // rule here so "Keep separate" can be undone. + const checkoutControls = (member: SidebarProjectGroupMember) => ( +
    +
    + +
    + {hasMultipleCheckouts ? ( + + ) : null} +
    ); const checkoutChoices = ( +

    + Grouping rule decides how each checkout joins project groups in the sidebar. Changing it can + move a checkout to a different project. +

    {Array.from(checkoutsByEnvironment, ([environmentId, members]) => { if (checkoutsByEnvironment.size === 1) { return members.map((member) => ( @@ -354,7 +444,7 @@ function ProjectDetail({ key={member.physicalProjectKey} title={member.environmentLabel ?? "Environment"} description={member.workspaceRoot} - control={checkoutRemoveButton(member)} + control={checkoutControls(member)} /> )); } @@ -383,26 +473,26 @@ function ProjectDetail({ ) : null} } - control={hasMultiple ? undefined : checkoutRemoveButton(firstMember)} + control={hasMultiple ? undefined : checkoutControls(firstMember)} > {hasMultiple ? (
    {members.map((member) => (
    + } > {member.workspaceRoot} {member.workspaceRoot} -
    {checkoutRemoveButton(member)}
    +
    {checkoutControls(member)}
    ))}
    @@ -565,7 +655,7 @@ function ProjectDetail({
    - {hasMultipleCheckouts ? checkoutChoices : null} + {checkoutChoices} ({ updateModelPreferences: vi.fn(), toggleFavorite: vi.fn(), mutateProviderInstance: vi.fn(), + confirm: vi.fn<() => Promise>(), })); vi.mock(import("../../state/server"), async (original) => { @@ -80,6 +81,14 @@ vi.mock(import("../../state/use-atom-command"), async (original) => ({ "accept-url-auth": state.acceptUrlAuth, })[command.label ?? ""] ?? vi.fn()) as never, })); +vi.mock(import("~/localApi"), async (original) => { + const api = { dialogs: { confirm: state.confirm } }; + return { + ...(await original()), + readLocalApi: (() => api) as never, + ensureLocalApi: (() => api) as never, + }; +}); vi.mock(import("../../state/query"), async (original) => ({ ...(await original()), useEnvironmentQuery: ((query: unknown) => ({ @@ -231,6 +240,7 @@ async function setup() { state.updateModelPreferences.mockReset(); state.toggleFavorite.mockReset(); state.mutateProviderInstance.mockReset().mockResolvedValue(AsyncResult.success({})); + state.confirm.mockReset().mockResolvedValue(false); state.refresh.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.updateProvider.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.uninstall.mockReset().mockResolvedValue(AsyncResult.success({})); @@ -359,6 +369,90 @@ describe("EnvironmentProviderSettings routing", () => { }); }); +describe("EnvironmentProviderSettings instance removal", () => { + it("resets a configured default instance only once the user confirms", async () => { + await setup(); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { binaryPath: "/opt/codex/bin/codex" }, + }, + }, + }; + await setProviders([provider()]); + await using _screen = await render(panel({ targetInstanceId: codexId })); + const reset = page.getByRole("button", { name: "Reset Codex provider settings to default" }); + await userEvent.click(reset); + + expect(state.mutateProviderInstance).not.toHaveBeenCalled(); + expect(state.confirm).toHaveBeenCalledOnce(); + + state.confirm.mockResolvedValue(true); + await userEvent.click(reset); + await vi.waitFor(() => + expect(state.mutateProviderInstance).toHaveBeenCalledWith({ + operation: "remove", + instanceId: codexId, + }), + ); + }); + + it("warns that resetting an enabled default-off provider turns it off", async () => { + await setup(); + const antigravityId = ProviderInstanceId.make("antigravity"); + const antigravity = ProviderDriverKind.make("antigravity"); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [antigravityId]: { + driver: antigravity, + enabled: true, + config: { binaryPath: "/opt/antigravity/bin/agy" }, + }, + }, + }; + await setProviders([{ ...provider(), instanceId: antigravityId, driver: antigravity }]); + await using _screen = await render(panel({ targetInstanceId: antigravityId })); + await userEvent.click(page.getByRole("button", { name: /^Reset .* provider settings/ })); + + expect(state.confirm).toHaveBeenCalledWith( + expect.stringContaining("It will also be turned off."), + { + variant: "destructive", + }, + ); + expect(state.mutateProviderInstance).not.toHaveBeenCalled(); + }); + + it("deletes a custom instance only once the user confirms", async () => { + await setup(); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [customId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + }; + await using _screen = await render(panel({ targetInstanceId: customId })); + const remove = page.getByRole("button", { name: `Delete instance ${customId}` }); + await userEvent.click(remove); + + expect(state.mutateProviderInstance).not.toHaveBeenCalled(); + expect(state.confirm).toHaveBeenCalledOnce(); + + state.confirm.mockResolvedValue(true); + await userEvent.click(remove); + await vi.waitFor(() => + expect(state.mutateProviderInstance).toHaveBeenCalledWith({ + operation: "remove", + instanceId: customId, + }), + ); + }); +}); + describe("EnvironmentProviderSettings health check interval", () => { it("commits interval drafts and allows retrying rejected edits", async () => { await setup(); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 243b5c85a0f07..990971973fd55 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -11,6 +11,7 @@ import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, + settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { @@ -47,6 +48,7 @@ import { useToggleEnvironmentModelFavorite, } from "../../hooks/useSettings"; import { useStableCallback } from "../../hooks/useStableCallback"; +import { ensureLocalApi } from "../../localApi"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { cn } from "../../lib/utils"; import { resolveAppModelSelectionState } from "../../modelSelection"; @@ -131,6 +133,25 @@ import { resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; +function providerInstanceName(instance: ProviderInstanceConfig): string { + return ( + instance.displayName?.trim() || + providerClients.get(instance.driver)?.label || + String(instance.driver) + ); +} + +async function confirmProviderInstanceRemoval( + question: string, + consequence = "", +): Promise { + const message = `${question}\nIts saved settings, including any custom binary path, will be removed.${consequence}`; + const confirmed = await settlePromise(() => + ensureLocalApi().dialogs.confirm(message, { variant: "destructive" }), + ); + return confirmed._tag === "Success" && confirmed.value; +} + function modelListChanges(current: ReadonlyArray, next: ReadonlyArray) { const currentSet = new Set(current); const nextSet = new Set(next.map((model) => model.trim()).filter(Boolean)); @@ -834,6 +855,10 @@ export function EnvironmentProviderSettings({ const deleteInstance = useStableCallback( async (instanceId: ProviderInstanceId, instance: ProviderInstanceConfig) => { + const confirmed = await confirmProviderInstanceRemoval( + `Remove the ${providerInstanceName(instance)} provider instance?`, + ); + if (!confirmed) return; const updateResult = await persistProviderInstance({ operation: "remove", instanceId, @@ -870,10 +895,17 @@ export function EnvironmentProviderSettings({ ); const resetDefaultInstance = useStableCallback(async (driverKind: ProviderDriverKind) => { - const result = await persistProviderInstance({ - operation: "remove", - instanceId: defaultInstanceIdForDriver(driverKind), - }); + const instanceId = defaultInstanceIdForDriver(driverKind); + const instance = settings.providerInstances?.[instanceId] ?? { driver: driverKind }; + const turnsOff = + resolveProviderInstanceEnabled(instance) && + !resolveProviderInstanceEnabled({ driver: driverKind }); + const confirmed = await confirmProviderInstanceRemoval( + `Reset the ${providerInstanceName(instance)} provider to its defaults?`, + turnsOff ? " It will also be turned off." : "", + ); + if (!confirmed) return; + const result = await persistProviderInstance({ operation: "remove", instanceId }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add({ diff --git a/apps/web/src/components/settings/SettingsSidebarNav.composition.browser.test.tsx b/apps/web/src/components/settings/SettingsSidebarNav.composition.browser.test.tsx new file mode 100644 index 0000000000000..5191b94cf49f8 --- /dev/null +++ b/apps/web/src/components/settings/SettingsSidebarNav.composition.browser.test.tsx @@ -0,0 +1,44 @@ +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; +import { SidebarProvider } from "../ui/sidebar"; +import { SettingsSidebarNav } from "./SettingsSidebarNav"; + +vi.mock("./useAvailableSettingsSearchItems", () => ({ + useAvailableSettingsSearchItems: () => [ + { id: "theme", title: "Theme", to: "/settings/appearance" }, + ], +})); + +it("ignores composing keys in the search", async () => { + await using screen = await render( + + + , + { router: "/settings/general" }, + ); + const search = page.getByRole("combobox", { name: "Search settings" }); + await search.fill("theme"); + await expect.element(page.getByRole("option", { name: /Theme/ })).toBeVisible(); + + for (const composition of [ + { key: "Enter", isComposing: true, keyCode: 13 }, + { key: "Enter", isComposing: false, keyCode: 229 }, + { key: "Escape", isComposing: true, keyCode: 27 }, + { key: "Escape", isComposing: false, keyCode: 229 }, + { key: "ArrowDown", isComposing: true, keyCode: 40 }, + { key: "ArrowUp", isComposing: true, keyCode: 38 }, + ]) { + const event = new KeyboardEvent("keydown", { ...composition, bubbles: true, cancelable: true }); + await act(() => search.element().dispatchEvent(event)); + expect(event.defaultPrevented, JSON.stringify(composition)).toBe(false); + expect(screen.router?.state.location.pathname).toBe("/settings/general"); + await expect.element(search).toHaveValue("theme"); + } + + await userEvent.keyboard("{Enter}"); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe("/settings/appearance"), + ); +}); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 7ad6e4dc9d946..06715ff5ae39e 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -233,6 +233,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }; const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Escape" && isSearching) { event.preventDefault(); event.stopPropagation(); @@ -277,10 +278,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { )} > + {/* Native search inputs clear on Escape even during IME composition. */} { setQuery(event.currentTarget.value); diff --git a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx index 5662954ef7cd6..df73fa3b24b45 100644 --- a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx +++ b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx @@ -101,7 +101,9 @@ export function SourceControlAppSignInSettings({ readHostCredentialSettings(settings, definition.kind), ); const signedIn = (signIns[host] ?? "").length > 0; - const tokenOverrides = (tokens[host] ?? "").length > 0; + const token = tokens[host]; + // A token read from a secret provider overrides the sign-in like a saved one. + const tokenOverrides = typeof token === "string" ? token.length > 0 : token !== undefined; const canSignIn = useAtomValue( sourceControlEnvironment.startSignIn.permissionAtom(environmentId), ); diff --git a/apps/web/src/components/settings/SourceControlTokenSettings.tsx b/apps/web/src/components/settings/SourceControlTokenSettings.tsx index 912de7ad91701..5a78b5f28287d 100644 --- a/apps/web/src/components/settings/SourceControlTokenSettings.tsx +++ b/apps/web/src/components/settings/SourceControlTokenSettings.tsx @@ -1,4 +1,9 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { + isSecretReference, + type EnvironmentId, + type SecretReference, + type SettingsSecretValue, +} from "@t3tools/contracts"; import { ExternalLinkIcon } from "lucide-react"; import { useState } from "react"; @@ -12,8 +17,11 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button, InlineButton } from "../ui/button"; import { Input } from "../ui/input"; -import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; + +const hasToken = (token: SettingsSecretValue | undefined) => + typeof token === "string" ? token.length > 0 : token !== undefined; export function SourceControlTokenSettings({ environmentId, @@ -34,15 +42,24 @@ export function SourceControlTokenSettings({ }); const defaultHost = definition.publicHost ?? ""; const [host, setHost] = useState(defaultHost); - const [draft, setDraft] = useState(""); + const secretSources = useSecretSources(environmentId); + const [pending, setPending] = useState<{ + readonly key: string; + readonly draft: string | SecretReference; + } | null>(null); const [saving, setSaving] = useState(false); const normalizedHost = host.trim().toLowerCase(); - const isSaved = (tokens[normalizedHost] ?? "").length > 0; + const savedToken = tokens[normalizedHost]; + const isSaved = hasToken(savedToken); const savedHosts = Object.entries(tokens) - .filter(([, value]) => value.length > 0) + .filter(([, value]) => hasToken(value)) .map(([saved]) => saved); + const sourceKey = JSON.stringify([normalizedHost, savedToken ?? ""]); + const draft = + pending?.key === sourceKey ? pending.draft : isSecretReference(savedToken) ? savedToken : ""; + const submission = typeof draft === "string" ? draft.trim() : draft; - const save = async (target: string, token: string) => { + const save = async (target: string, token: SettingsSecretValue) => { setSaving(true); const result = await updateSettings({ environmentId, @@ -55,7 +72,7 @@ export function SourceControlTokenSettings({ }); setSaving(false); if (result._tag === "Success") { - setDraft(""); + setPending(null); onSaved(); } }; @@ -65,7 +82,7 @@ export function SourceControlTokenSettings({ className="grid gap-3" onSubmit={(event) => { event.preventDefault(); - if (normalizedHost && draft.trim()) void save(normalizedHost, draft.trim()); + if (normalizedHost && submission !== "") void save(normalizedHost, submission); }} > {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */} @@ -106,12 +123,15 @@ export function SourceControlTokenSettings({
    - setDraft(event.target.value)} + sources={secretSources} + onChange={(event) => setPending({ key: sourceKey, draft: event.target.value })} + onSecretChange={(secret) => setPending({ key: sourceKey, draft: secret ?? "" })} />
    @@ -132,7 +152,11 @@ export function SourceControlTokenSettings({ Remove ) : null} -
    diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 3ee0d1463c116..bfb86dd7250d0 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -615,7 +615,9 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "provider-update-checks", title: "Provider update checks", to: "/settings/maintenance", - searchTerms: ["installed cli versions newer available codex claude cursor grok fx opencode"], + searchTerms: [ + "installed cli versions newer available codex claude cursor grok fx kiro opencode", + ], scope: "environment-defaults", }, { @@ -831,7 +833,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok fx opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", + "agents cli codex claude cursor grok fx kiro opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", ], }, { diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index 10581ecb5a679..bc15ca5945972 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -82,8 +82,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { return (
  • \n \n );\n}\n", "apps/web/src/tasks/api.ts": "export const stopTask = (id: string) => fetch(`/api/tasks/${id}/stop`, { method: 'POST' });\n"}}}, "prompt": "In the task list, the Stop button on a running background task does nothing: the task keeps showing Running and its worker thread keeps going. Wire it up.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "orchestrator_capabilities", "task_cancel", "task_status", "t3_thread_interrupt", "t3_thread_send"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": ""} +{"id": "tt-control-task-queue", "area": "orchestration", "kind": "control", "pattern": "Domain task queue lookup", "experience": "A question about the app's task queue limit is ordinary code work, not a T3 task.", "rule": "Ordinary coding tasks need no T3 tools.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Explain the configured limit in src/task-queue.ts. This is the application task queue.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "Workflow", "orchestrator_capabilities", "task_status", "task_cancel"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."} +{"id": "tt-control-agent-config", "area": "orchestration", "kind": "control", "pattern": "Domain agent configuration lookup", "experience": "A question about the app's agent label setting is ordinary code work; it starts no agent.", "rule": "Ordinary coding tasks need no T3 tools.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Read src/agent-config.ts and tell me the current default agent label in this application.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "Workflow", "orchestrator_capabilities"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."} +{"id": "tt-background-research-no-agent-word", "area": "orchestration", "kind": "guard", "pattern": "bare 'in the background' ask: delegate_task", "experience": "Asked to look into something in the background without the word 'agent', the agent must start a delegate_task, not a background native Agent or a new thread.", "rule": "Background agent work goes to delegate_task; Claude's Agent runs with run_in_background:false. / t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/cache/export.ts": "export function exportCache(entries: Map) {\n return [...entries].filter(([, value]) => value !== '').map(([key, value]) => ({ key, value }));\n}\n"}}}, "prompt": "In the background, find out why src/cache/export.ts drops entries that docs/cache.md says must be kept, and report back when you know. I'll keep going on the release notes here.", "expect": {"calls": [{"tool": "delegate_task", "args": {"task": "~cache"}}], "notCalls": ["t3_thread_launch", "create_threads", "Workflow", "t3_thread_wait"], "callsMustNot": [{"tool": "Agent", "args": {"run_in_background": true}}, {"tool": "delegate_task", "args": {"mode": "wait"}}], "maxCalls": {"task_status": 0}}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard", "review": "council review round 1: bare background ask without 'agent'"}, "notes": "Synthetic Acme scenario. Pairs with tt-background-agent; checks the rule does not depend on the word 'agent'."} +{"id":"tt-control-background-test-run","area":"orchestration","kind":"control","pattern":"background shell command is not a delegate","experience":"Asked to run the test suite in the background while refactoring, the agent should use an ordinary background shell command, not a delegate, subagent or thread.","rule":"Ordinary coding tasks need no T3 tools.","privacy":"commit-safe","setup":{"role":"top","context":"Repository: /home/user/app (acme/app). This is the local machine.","world":{"repo":"acme/app","branch":"main","status":"","files":{"package.json":"{\"scripts\":{\"test\":\"vitest run\"}}\n","src/export/csv.ts":"export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, r.total].join(',')).join('\\n');\n}\n"}}},"prompt":"Kick off the full test suite in the background, and meanwhile rename toCsv to rowsToCsv in src/export/csv.ts.","expect":{"notCalls":["delegate_task","t3_thread_launch","create_threads","Workflow","orchestrator_capabilities"],"callsMustNot":[{"tool":"Agent","args":{"run_in_background":true}}],"calls":[{"tool":"Bash","args":{"command":"~test"}}]},"source":{"request":"juliusmarminge (maintainer) request, 2026-10-29; regression guard","review":"council review round 1: over-correction control for 'background'"},"notes":"Synthetic Acme scenario. 'Background' here means a shell command, not an agent."} diff --git a/evals/mcp-tool-use/devices.test.ts b/evals/mcp-tool-use/devices.test.ts index 444e5850c64e5..40c7efb92a82f 100644 --- a/evals/mcp-tool-use/devices.test.ts +++ b/evals/mcp-tool-use/devices.test.ts @@ -1,7 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodeZlib from "node:zlib"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -100,7 +102,7 @@ it.effect("answers device calls with T3's handlers over the case's world", () => const elsewhere = yield* tools.call("device_open", { hostId: "local", deviceId: IPHONE }); assert.isTrue(elsewhere.isError); assert.include(elsewhere.text, IPHONE); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("lists the boot state T3 left a device in", () => @@ -113,7 +115,7 @@ it.effect("lists the boot state T3 left a device in", () => yield* tools.call("device_open", { hostId: MAC, deviceId: IPHONE }); const after = json((yield* tools.call("device_list", {})).text); assert.isTrue(after.devices.find((d: { id: string }) => d.id === IPHONE).booted); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it("decodes only T3 device errors as injected failures", () => { @@ -148,7 +150,7 @@ it.effect("rebuilds prior device results with T3, whose sessions reach the measu assert.isFalse((yield* tools.call("device_screenshot", {})).isError); const mock = deviceFailure(testCase.setup.mocks?.device_screenshot); assert.isTrue((yield* tools.call("device_screenshot", {}, mock)).isError); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("gives a native subagent T3's SubagentStart context instead of T3's append", () => @@ -204,7 +206,7 @@ it.effect("tells the shell simulator the launchers T3 returned and agent-device' // A case without devices keeps the shell prompt every other case has. const plain = makeSample(deviceCase({ world: {} }), base); assert.notInclude(shellRequest(plain, call, "claude-haiku-5-5").system, "agent-device"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it("prints agent-device's own help for a lone help command on a returned launcher", () => { @@ -315,7 +317,7 @@ it.effect("answers the returned launcher as agent-device does before and after o tools.sessionError(history, `${line("screenshot /tmp/a.png")} 2>&1`), NO_SESSION, ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const shared = (session: string): LauncherTarget => ({ @@ -484,7 +486,7 @@ it.effect("starts sessions the world says prior turns opened, and rejects unknow const unknown = { devices, deviceHosts, agentDeviceSessions: { "not-a-device": {} } }; const exit = yield* Effect.exit(makeDeviceTools(unknown, base.environment)); assert.isTrue(exit._tag === "Failure"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -522,7 +524,7 @@ it.effect( }); assert.isUndefined(cannedOutput(sample, `${command} screenshot /tmp/a.png${flags}`)); assert.include(told(), "is open with an app"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it("keeps a redirection's descriptor out of the app an open names, on either launcher", () => { @@ -631,7 +633,7 @@ it.effect("lists a host whose discovery fails as T3 does: failed, with none of i deviceTools({ deviceDiscoveryFailures: { "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee": failure } }), ); assert.isTrue(unlabeled._tag === "Failure"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("denies every device tool with T3's own error when agent device access is off", () => @@ -657,7 +659,7 @@ it.effect("denies every device tool with T3's own error when agent device access } // T3 drops its device text for a thread without the capability, on refs that have it. assert.notInclude(t3SystemAppend(model, false), "## T3 Code devices"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reports an unreadable capture as T3 does: a successful 0×0 screenshot", () => @@ -674,7 +676,7 @@ it.effect("reports an unreadable capture as T3 does: a successful 0×0 screensho yield* withDevices(sample); const told = JSON.stringify(shellRequest(sample, { tool: "Bash", input: {} }, model).messages); assert.include(told, "agent-device's own screenshot finds the PNG and is unaffected"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("refuses an open on a device another thread's agent-device session holds", () => @@ -742,7 +744,7 @@ it.effect("refuses an open on a device another thread's agent-device session hol history.push({ tool: "Bash", input: { command: line("close", owner) }, output: "Closed" }); assert.equal(answer("open com.acme.app"), ""); assert.include(describeSessions(tools.sessions(history), tools.owners), "was closed"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("leaves a free device free when another thread holds a different one", () => @@ -762,7 +764,7 @@ it.effect("leaves a free device free when another thread holds a different one", assert.isFalse( (yield* tools.call("device_list", {})).text.includes(`"hostId":"${LOCAL_DEVICE_HOST_ID}"`), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); type Opened = { command: string; targetArgs: Array; session?: string }; @@ -883,7 +885,7 @@ it.effect( JSON.stringify(request.messages), `${session(own)} (Acme iPhone, ${IPHONE}, on build-mac) has not started`, ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it("tells the simulator 0.21.31's --foreground and session list behaviour", () => { diff --git a/evals/mcp-tool-use/devices.ts b/evals/mcp-tool-use/devices.ts index 3a36e3a62dcd1..9158028e8a395 100644 --- a/evals/mcp-tool-use/devices.ts +++ b/evals/mcp-tool-use/devices.ts @@ -21,7 +21,7 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +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"; @@ -695,7 +695,7 @@ export const makeDeviceTools = Effect.fn("evaluateMcpToolUse.makeDeviceTools")(f Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const { stateDir } = yield* ServerConfig.ServerConfig; const agentEntry = path.join(home, ".t3", "tools", "agent-device", "bin", "agent-device.mjs"); const hosts = new Map([ @@ -720,7 +720,7 @@ export const makeDeviceTools = Effect.fn("evaluateMcpToolUse.makeDeviceTools")(f // As DeviceService.make, minus writing the config file the path names. const configureAgent = (hostId: DeviceHostId, _endpoint: unknown, deviceId?: string) => agentDeviceConfigPath(stateDir, hostId, path, deviceId).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), Effect.mapError( (cause) => new DeviceOperationError({ @@ -775,7 +775,7 @@ export const makeDeviceTools = Effect.fn("evaluateMcpToolUse.makeDeviceTools")(f ); const context = yield* Layer.build( McpHttpServer.layerDeviceToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provideMerge(devices), Layer.provideMerge( Layer.mergeAll( diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index daaccccfcba96..e696bbd2154ea 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -1,6 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { assert, expect, it } from "@effect/vitest"; @@ -32,6 +34,7 @@ import { shellOutput, shellRequest, summarize, + t3Default, t3SystemAppend, t3SubagentContext, withDevices, @@ -79,6 +82,54 @@ it("accepts any tool listed as an alternative", () => { ]); }); +it("counts the new threads an agent asks for, one brief per thread, over both creation tools", () => { + const expect = { newThreads: ["~reconnect", "~csv", "~admin"] }; + const run = (...calls: Array<{ tool: string; input: unknown; failed?: boolean }>): SampleRun => ({ + calls: calls.map((call, step) => ({ step, ...call })), + text: "", + stopReason: "end_turn", + }); + const batch = (...prompts: Array) => ({ + tool: "create_threads", + input: { threads: prompts.map((prompt) => ({ prompt })) }, + }); + const launch = (message: string, failed = false) => ({ + tool: "t3_thread_launch", + input: { message }, + failed, + }); + const label = 'newThreads ["~reconnect","~csv","~admin"]'; + assert.deepEqual(failed(expect, run(batch("Fix reconnect", "Fix CSV dates", "Fix admin"))), []); + assert.deepEqual( + failed(expect, run(launch("Fix reconnect"), batch("Fix CSV dates", "Fix admin"))), + [], + ); + assert.deepEqual( + failed(expect, run(launch("Fix reconnect", true), batch("Fix reconnect", "csv", "admin"))), + [], + ); + assert.deepEqual(failed(expect, run(batch("reconnect", "csv"), launch("csv and admin"))), []); + assert.deepEqual(failed(expect, run(batch("Fix reconnect, csv and admin"))), [label]); + assert.deepEqual(failed(expect, run(batch("reconnect, csv, admin", "csv", "csv"))), [label]); + assert.deepEqual(failed(expect, run(batch("reconnect", "csv", "admin", "docs"))), [label]); + for (const call of [ + { tool: "t3_thread_launch", input: { prompt: "csv" } }, + { tool: "create_threads", input: { threads: [{ message: "csv" }] } }, + ]) { + assert.deepEqual(failed(expect, run(launch("reconnect"), launch("admin"), call)), [label]); + } + assert.deepEqual( + failed( + expect, + run(launch("reconnect"), launch("admin"), { + tool: "t3_thread_launch", + input: { title: "CSV", message: "Fix reconnect" }, + }), + ), + [label], + ); +}); + it("runs code-mode calls without exposing host capabilities, including after await", async () => { const catalog = [{ name: "mock", description: "mock" }]; const result = await executeCodex( @@ -354,6 +405,57 @@ it("replays earlier capability results with the model under test as the caller", assert.include(String(replayed?.content), '"inheritedModel":"claude-opus-5-5"'); }); +it("gives each launched thread its own id, and reading it back shows that thread's brief", () => { + const base = buildBase(template, { model: "claude-sonnet-5-5", keepUserInstructions: false }); + const sample = makeSample(childCase, base); + const one = t3Default(sample, "t3_thread_launch", { + title: "Reconnect", + message: "Fix reconnect", + }); + const batch = t3Default(sample, "create_threads", { + threads: [ + { title: "CSV", prompt: "Fix CSV dates" }, + { prompt: "Fix admin expiry", target: { providerInstanceId: "muse", model: "muse-wonder" } }, + ], + }); + const created = (batch as { threads: Array<{ providerInstanceId: string; model: string }> }) + .threads; + assert.deepEqual( + created.map((thread) => [thread.providerInstanceId, thread.model]), + [ + ["claudeAgent", "claude-sonnet-5-5"], + ["muse", "muse-wonder"], + ], + ); + const ids = [one, ...(batch as { threads: Array }).threads].map( + (thread) => (thread as { threadId: string }).threadId, + ); + assert.equal(new Set(ids).size, 3); + const read = (threadId: unknown) => + t3Default(sample, "t3_thread_read", { threadId }) as { + thread: { title: string; status: string; createdBy?: string }; + items: Array<{ text: string; createdBy: string; creationSource: string }>; + }; + const idle = t3Default(sample, "create_threads", { threads: [{ title: "Later" }] }) as { + threads: Array<{ threadId: string; status: string; runId?: string }>; + }; + assert.equal(idle.threads[0]?.status, "idle"); + assert.isUndefined(idle.threads[0]?.runId); + assert.equal(read(idle.threads[0]?.threadId).thread.status, "idle"); + assert.deepEqual(read(idle.threads[0]?.threadId).items, []); + assert.equal(read(ids[1]).thread.title, "CSV"); + assert.equal(read(ids[2]).items[0]?.text, "Fix admin expiry"); + assert.equal(read(ids[2]).thread.createdBy, "agent"); + assert.deepEqual( + [read(ids[2]).items[0]?.createdBy, read(ids[2]).items[0]?.creationSource], + ["agent", "mcp"], + ); + assert.equal( + read("11111111-1111-4111-8111-111111111111").thread.title, + "Fix the flaky reconnect test", + ); +}); + it("does not record a deferred tool called before it was loaded", () => { const base = buildBase(template, { model: "claude-sonnet-5-5", keepUserInstructions: false }); const sample = makeSample(childCase, base); @@ -558,7 +660,7 @@ it.effect("builds device cases from product-backed worlds and synthetic prior tu assert.equal(native.kind, "bad"); assert.equal(native.setup.harness, "claude"); assert.isNotEmpty(cases.find((c) => c.id === "SEED-01")!.setup.priorTurns!); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.each([ @@ -1276,7 +1378,7 @@ it.effect("accepts a successful exact-host recovery after a real device_open err stopReason: "end_turn", }).every((c) => c.pass), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it("keeps subshell-only launcher aliases out of the parent shell", () => { @@ -2934,7 +3036,7 @@ it.effect("fleet case worlds survive decoding and reach the product-backed runne assert.include(error, id === "OWN-01" ? "DEVICE_IN_USE" : "SESSION_NOT_FOUND"); if (id === "OWN-01") assert.equal(tools.owners[0]!.session, "t3-a062c547ef0b6bf8ee839b67"); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.each([ @@ -3108,7 +3210,7 @@ it.effect("R12 OWN-01 protects occupied panel sessions from selected and wildcar }), [], ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -3143,7 +3245,7 @@ it.effect( calls: [...calls, calls.at(-1)!, ...good.calls.slice(1)], }), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const capRun6 = [ diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index 264737314e3f4..e394e3983fdbd 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -11,6 +11,7 @@ import * as NodeUtil from "node:util"; import * as NodeVM from "node:vm"; import * as NodeZlib from "node:zlib"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ComposerContextRecord, @@ -171,6 +172,11 @@ const McpToolUseExpect = Schema.Struct({ order: Schema.optional(Schema.Array(Schema.String)), minCalls: Schema.optional(Schema.Record(Schema.String, Schema.Number)), maxCalls: Schema.optional(Schema.Record(Schema.String, Schema.Number)), + /** + * One `args`-style value per new top-level thread the agent asks for, by `t3_thread_launch` or a + * `create_threads` entry. Each matches a different thread's request; no other thread is asked for. + */ + newThreads: Schema.optional(Schema.Array(Schema.Unknown)), /** The last step with tool calls includes this tool, then the turn ends. */ endsTurnAfter: Schema.optional(Schema.String), /** Regexes that no shell command may match. */ @@ -745,7 +751,8 @@ const createdPullRequests = ({ tool, input, output }: ShellEntry) => : []; /** * One sample's state. ToolSearch adds loaded schemas to `tools`, like Claude Code. `shell` is the - * history the simulator keeps consistent with; `linked` and `nextTask` do the same for T3 results. + * history the simulator keeps consistent with; `linked`, `nextTask` and `threads` do the same for + * T3 results. */ type Sample = { readonly testCase: McpToolUseCase; @@ -757,6 +764,8 @@ type Sample = { readonly linked: Set; readonly codeStore: Record; nextTask: number; + /** Threads the agent launched in this sample, by id, so reading one shows its own brief. */ + readonly threads: Map; /** T3's device tools, once `withDevices` builds them for a world with devices. */ devices?: DeviceTools; /** T3's results for prior device calls whose case result is "default". */ @@ -801,6 +810,7 @@ export const makeSample = (testCase: McpToolUseCase, base: Base): Sample => { linked: new Set([...urls, ...shell.flatMap(createdPullRequests)]), codeStore: {}, nextTask: Math.max(0, ...taskNumbers) + 1, + threads: new Map(), priorResults: new Map(), }; }; @@ -1013,9 +1023,41 @@ const linkedEntry = (sample: Sample, url: string) => { }; /** A believable T3 result for a tool the case does not mock. */ -const t3Default = (sample: Sample, name: string, input: Readonly>) => { +export const t3Default = ( + sample: Sample, + name: string, + input: Readonly>, +) => { const pr = () => pullRequest(sample.repo, input); + const newThread = (request: unknown, brief: unknown) => { + const n = sample.nextTask++; + const title = + isRecord(request) && typeof request.title === "string" ? request.title : "New thread"; + const text = typeof brief === "string" ? brief.trim() : ""; + sample.threads.set(uuid(n), { title, brief: text }); + return { + threadId: uuid(n), + title, + ...(text ? { runId: `run-${n}`, status: "starting" } : { status: "idle" }), + }; + }; switch (name) { + case "t3_thread_launch": + return newThread(input, input.message); + case "create_threads": + return { + threads: (Array.isArray(input.threads) ? input.threads : []).map((request) => { + const target = isRecord(request) && isRecord(request.target) ? request.target : {}; + return { + ...newThread(request, isRecord(request) ? request.prompt : undefined), + createdBy: "agent", + creationSource: "mcp", + providerInstanceId: + target.providerInstanceId ?? (sample.base.codex ? "codex" : "claudeAgent"), + model: target.model ?? sample.base.model, + }; + }), + }; case "orchestrator_capabilities": return { inheritedProviderInstanceId: "claudeAgent", @@ -1121,6 +1163,20 @@ const t3Default = (sample: Sample, name: string, input: Readonly { return Object.entries(expected).every(([path, value]) => matches(value, at(path))); }; +/** Each new top-level thread request in a run; a failed call created nothing. */ +const newThreadRequests = (calls: ReadonlyArray) => + calls.flatMap((call): ReadonlyArray<{ request: unknown; brief: unknown }> => { + if (call.failed === true || !isRecord(call.input)) return []; + if (call.tool === "t3_thread_launch") + return [{ request: call.input, brief: call.input.message }]; + if (call.tool !== "create_threads" || !Array.isArray(call.input.threads)) return []; + return call.input.threads.map((request: unknown) => ({ + request, + brief: isRecord(request) ? request.prompt : undefined, + })); + }); + +/** Whether every expected value can take a different request it matches. */ +const matchesDistinct = ( + expected: ReadonlyArray, + requests: ReturnType, + taken: ReadonlySet = new Set(), +): boolean => { + const [first, ...rest] = expected; + if (expected.length === 0) return true; + return requests.some( + ({ request, brief }, i) => + !taken.has(i) && + matches(first, typeof first === "string" ? brief : request) && + matchesDistinct(rest, requests, new Set([...taken, i])), + ); +}; + /** delegate_task takes `target.options` as an object or `[{id, value}]`. Grade both as an object. */ const withOptionsObject = (input: unknown) => { if (!isRecord(input) || !isRecord(input.target) || !Array.isArray(input.target.options)) { @@ -1501,6 +1586,13 @@ export const gradeSample = (expect: McpToolUseCase["expect"], run: SampleRun) => add(`minCalls ${t} >= ${n}`, count(t) >= n); for (const [t, n] of Object.entries(expect.maxCalls ?? {})) add(`maxCalls ${t} <= ${n}`, count(t) <= n); + if (expect.newThreads !== undefined) { + const requests = newThreadRequests(calls); + add( + `newThreads ${valueText(expect.newThreads)}`, + requests.length === expect.newThreads.length && matchesDistinct(expect.newThreads, requests), + ); + } if (expect.endsTurnAfter !== undefined) { const lastStep = Math.max(-1, ...calls.map((call) => call.step)); const last = calls.filter((call) => call.step === lastStep).map((call) => call.tool); @@ -2264,7 +2356,7 @@ const main = Effect.gen(function* () { if (import.meta.main) { main.pipe( - Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, FetchHttpClient.layer)), NodeRuntime.runMain, ); } diff --git a/evals/thread-titles/run.ts b/evals/thread-titles/run.ts index 9195d0ed520a2..2a68f8cfd1cee 100644 --- a/evals/thread-titles/run.ts +++ b/evals/thread-titles/run.ts @@ -3,6 +3,7 @@ import * as NodeUtil from "node:util"; import * as NodeCrypto from "node:crypto"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CodexSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -153,7 +154,7 @@ await Effect.runPromise( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-title-evaluation-state-" }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer)), ), ), Effect.scoped, diff --git a/infra/relay/package.json b/infra/relay/package.json index 197c37819981e..d69e71dfa5f01 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -30,6 +30,7 @@ "effect": "catalog:" }, "devDependencies": { + "@alchemy.run/test": "catalog:", "@cloudflare/workers-types": "^4.20260601.1", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts index 5ab8b114c8dc9..5f052f58e4c17 100644 --- a/infra/relay/scripts/android-push-watch.ts +++ b/infra/relay/scripts/android-push-watch.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Local developer verification reads private credential files. import * as NodeFSP from "node:fs/promises"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import * as NodeWS from "@effect/platform-node/NodeWS"; import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS, @@ -89,7 +89,7 @@ const main = Effect.gen(function* () { const layerSocketConstructor = Layer.succeed( Socket.WebSocketConstructor, (url, protocols) => - new NodeSocket.NodeWS.WebSocket(url, protocols as string | string[] | undefined, { + new NodeWS.WebSocket(url, protocols as string | string[] | undefined, { headers: { authorization: `Bearer ${connection.bearerToken}` }, }) as unknown as globalThis.WebSocket, ); diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index b14183c9d29b4..5b633b0c4e6e6 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -1,6 +1,6 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "@alchemy.run/cloudflare"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -78,7 +78,7 @@ export class ApnsDeliveryQueue extends Context.Service< const make = Effect.gen(function* () { const sender = yield* ApnsDeliveryQueueSender; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const config = yield* RelayConfiguration.RelayConfiguration; return ApnsDeliveryQueue.of({ @@ -89,7 +89,7 @@ const make = Effect.gen(function* () { "relay.delivery.kind": input.kind, }); const now = yield* DateTime.now; - const jobId = yield* crypto.randomUUIDv4().pipe( + const jobId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new ApnsDeliveryQueueSendError({ @@ -148,7 +148,7 @@ const make = Effect.gen(function* () { "relay.thread_id": input.notification.threadId, }); const now = yield* DateTime.now; - const jobId = yield* crypto.randomUUIDv4().pipe( + const jobId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new ApnsDeliveryQueueSendError({ diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index 161fa48cbce84..fef33de106d55 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { and, eq, inArray, isNull, lt } from "drizzle-orm"; import { QueryBuilder } from "drizzle-orm/pg-core"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Schema from "effect/Schema"; import * as RelayDb from "../db.ts"; @@ -97,7 +97,7 @@ function insertValues( const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const isExpiredClaim = (claimedAt: string | null, now: DateTime.DateTime) => { if (claimedAt === null) { @@ -121,7 +121,7 @@ const make = Effect.gen(function* () { ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), }); return yield* Effect.gen(function* () { - const id = yield* crypto.randomUUIDv4(); + const id = yield* random.uuidV4(); const now = yield* DateTime.now; const createdAt = DateTime.formatIso(now); const inserted = yield* db diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 615840fe27aeb..1c6cc0a6b71ff 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -5,7 +5,7 @@ import { RelayAgentAwarenessPreferences, type RelayDeliveryResult, } from "@t3tools/contracts/relay"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import type * as PlatformError from "effect/PlatformError"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -139,7 +139,7 @@ export class FcmDeliveries extends Context.Service< const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const sender = yield* FcmDeliveryQueueSender.FcmDeliveryQueueSender; const client = yield* FcmClient.FcmClient; const devices = yield* LiveActivities.LiveActivities; @@ -297,7 +297,10 @@ const make = Effect.gen(function* () { }; if (alert) { // Hash the full stable group identity to stay within the payload budget. - const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(alert.alert_id)); + const digest = yield* crypto.digest( + Digest.sha256, + new TextEncoder().encode(alert.alert_id), + ); data.alert_id = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); } const result = yield* client.send({ diff --git a/infra/relay/src/clientConfig.test.ts b/infra/relay/src/clientConfig.test.ts index c1c27a77639a2..cf61820a6a2cf 100644 --- a/infra/relay/src/clientConfig.test.ts +++ b/infra/relay/src/clientConfig.test.ts @@ -1,6 +1,6 @@ // @effect-diagnostics anyUnknownInErrorContext:off - ScratchStack.deploy is typed with `any` in its error channel by Alchemy. import { inMemoryState } from "alchemy/State"; -import * as Test from "alchemy/Test/Vitest"; +import * as Test from "@alchemy.run/test/Vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index 09253cc2cf1fd..ad22b54f9ae3f 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -30,7 +30,7 @@ import { } from "@t3tools/shared/relayJwt"; import { stableStringify } from "@t3tools/shared/relaySigning"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -386,7 +386,7 @@ const make = Effect.gen(function* () { const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; const settings = yield* RelayConfiguration.RelayConfiguration; const httpClient = yield* HttpClient.HttpClient; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const relayIssuer = normalizeRelayIssuer(settings.relayIssuer); const makeEnvironmentClient = (httpBaseUrl: string) => makeEnvironmentHttpApiClient(httpBaseUrl).pipe( @@ -444,7 +444,7 @@ const make = Effect.gen(function* () { }); const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { minutes: 2 }); - const nonce = yield* crypto.randomUUIDv4().pipe( + const nonce = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new EnvironmentMintRequestFailed({ @@ -458,7 +458,7 @@ const make = Effect.gen(function* () { iss: relayIssuer, aud: `t3-env:${link.environmentId}`, sub: input.userId, - jti: yield* crypto.randomUUIDv4().pipe( + jti: yield* random.uuidV4().pipe( Effect.mapError( (cause) => new EnvironmentMintRequestFailed({ @@ -606,7 +606,7 @@ const make = Effect.gen(function* () { }); const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { minutes: 2 }); - const nonce = yield* crypto.randomUUIDv4().pipe( + const nonce = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new EnvironmentMintRequestFailed({ @@ -620,7 +620,7 @@ const make = Effect.gen(function* () { iss: relayIssuer, aud: `t3-env:${link.environmentId}`, sub: input.userId, - jti: yield* crypto.randomUUIDv4().pipe( + jti: yield* random.uuidV4().pipe( Effect.mapError( (cause) => new EnvironmentMintRequestFailed({ diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts index 86938a47eb8de..de346caf64847 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -83,13 +84,15 @@ export class EnvironmentCredentials extends Context.Service< const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; const hashToken = (token: string) => - crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe(Effect.map(Base64Url.encode)); + digest + .digest(Digest.sha256, new TextEncoder().encode(token)) + .pipe(Effect.map(Base64Url.encode)); const randomTokenPart = (segments: number) => - Effect.map( - Effect.all(Array.from({ length: segments }, () => crypto.randomUUIDv4())), - (values) => values.join("").replaceAll("-", ""), + Effect.map(Effect.all(Array.from({ length: segments }, () => random.uuidV4())), (values) => + values.join("").replaceAll("-", ""), ); const makeCredential = Effect.fnUntraced(function* () { const credentialId = yield* randomTokenPart(2); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index bf96390d8d42d..e50e61810aad3 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign. import * as NodeCrypto from "node:crypto"; +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { RelayAgentActivityPublishProofPayload, @@ -98,7 +99,7 @@ function layer(replay?: Partial) { }), ), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCryptoLayer.layer)), ); } diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index 4c89bff5342b0..5206f6f482a86 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -11,7 +11,7 @@ import { } from "@t3tools/shared/relayJwt"; import { stableStringify } from "@t3tools/shared/relaySigning"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -93,7 +93,7 @@ const formatEnvironmentPublishReplayThumbprint = (digest: Uint8Array) => const make = Effect.gen(function* () { const proofReplay = yield* DpopProofs.DpopProofReplay; const config = yield* RelayConfiguration.RelayConfiguration; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; return EnvironmentPublishSignatures.of({ verify: Effect.fn("relay.environment_publish_signatures.verify")(function* (input) { @@ -170,7 +170,7 @@ const make = Effect.gen(function* () { } const thumbprint = yield* crypto .digest( - "SHA-256", + Digest.sha256, environmentPublishReplayThumbprintData({ environmentId: input.environmentId, environmentPublicKey: input.environmentPublicKey, diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index faa3ae637aecf..3e360d8f62160 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -1,8 +1,10 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Alchemy from "alchemy"; import * as Cloudflare from "@alchemy.run/cloudflare"; +import * as ZeroTrust from "@distilled.cloud/cloudflare/zero-trust"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -35,10 +37,25 @@ const config = RelayConfiguration.RelayConfiguration.of({ }); interface TunnelCall { - readonly operation: "get" | "list" | "create" | "putConfiguration" | "getToken" | "delete"; + readonly operation: + | "get" + | "list" + | "create" + | "putConfiguration" + | "getConfiguration" + | "getToken" + | "delete"; readonly input: unknown; } +interface TunnelConfiguration { + readonly ingress: ReadonlyArray<{ + readonly hostname?: string; + readonly path?: string; + readonly service: string; + }>; +} + interface DnsCall { readonly operation: "listRecords" | "createRecord" | "updateRecord" | "deleteRecord"; readonly input: unknown; @@ -87,6 +104,11 @@ function makeTunnelClient(calls: TunnelCall[] = []) { Effect.sync(() => { calls.push({ operation: "putConfiguration", input: { tunnelId, tunnelConfig } }); }), + getConfiguration: (tunnelId) => + Effect.sync(() => { + calls.push({ operation: "getConfiguration", input: tunnelId }); + return { ingress: [] }; + }), getToken: (tunnelId) => Effect.sync(() => { calls.push({ operation: "getToken", input: tunnelId }); @@ -99,7 +121,10 @@ function makeTunnelClient(calls: TunnelCall[] = []) { }); } -function makePersistentTunnelClient(calls: TunnelCall[] = []) { +function makePersistentTunnelClient( + calls: TunnelCall[] = [], + configuration: { current: TunnelConfiguration | null } = { current: null }, +) { let tunnel: { readonly id: string; readonly name: string } | null = null; return ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ get: (tunnelId) => @@ -133,6 +158,12 @@ function makePersistentTunnelClient(calls: TunnelCall[] = []) { putConfiguration: (tunnelId, tunnelConfig) => Effect.sync(() => { calls.push({ operation: "putConfiguration", input: { tunnelId, tunnelConfig } }); + configuration.current = tunnelConfig; + }), + getConfiguration: (tunnelId: string) => + Effect.sync(() => { + calls.push({ operation: "getConfiguration", input: tunnelId }); + return configuration.current ?? { ingress: [] }; }), getToken: (tunnelId) => Effect.sync(() => { @@ -365,7 +396,7 @@ function layerProvider( tunnelLimits = makeTunnelLimits(), ) { return ManagedEndpointProvider.layer.pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provide(RelayConfiguration.layer(config)), Layer.provide(ManagedEndpointProvider.layerTunnelClient(tunnelClient)), Layer.provide(ManagedEndpointProvider.layerDnsClient(dnsClient)), @@ -751,7 +782,7 @@ describe("ManagedEndpointProvider", () => { {} as Cloudflare.DNS.ReadWriteDnsClient, {} as Alchemy.BaseRuntimeContext, ).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provide(RelayConfiguration.layer(config)), Layer.provide( Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, makeAllocations()), @@ -786,6 +817,44 @@ describe("ManagedEndpointProvider", () => { }); }); + it.effect("reads a tunnel without remote configuration as having no ingress", () => { + const tunnelClient = { + getConfiguration: () => + Effect.fail( + new ZeroTrust.TunnelConfigurationNotFound({ + code: 1055, + message: "Tunnel configuration not found", + }), + ), + } as unknown as Cloudflare.Tunnel.ReadWriteTunnelClient; + const layer = ManagedEndpointProvider.layerCloudflareBindings( + tunnelClient, + {} as Cloudflare.DNS.ReadWriteDnsClient, + {} as Alchemy.BaseRuntimeContext, + ).pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + Layer.provide(RelayConfiguration.layer(config)), + Layer.provide( + Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, makeAllocations()), + ), + Layer.provide(Layer.succeed(ManagedTunnelLimits.ManagedTunnelLimits, makeTunnelLimits())), + Layer.provide( + Layer.succeed( + CloudflareApiPause.CloudflareApiPause, + CloudflareApiPause.CloudflareApiPause.of({ + pausedUntilMillis: Effect.succeed(0), + pauseUntil: () => Effect.void, + }), + ), + ), + ); + + return Effect.gen(function* () { + const tunnels = yield* ManagedEndpointProvider.ManagedEndpointTunnelClient; + expect(yield* tunnels.getConfiguration("tunnel-id")).toEqual({ ingress: [] }); + }).pipe(Effect.provide(layer)); + }); + it.effect("does not hide non-not-found checkpoint update failures", () => { const dnsCalls: DnsCall[] = []; const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ @@ -1138,6 +1207,72 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); + it.effect("restores Cloudflare ingress that drifted from the recorded origin", () => { + const tunnelCalls: TunnelCall[] = []; + const configuration: { current: TunnelConfiguration | null } = { current: null }; + const layer = layerProvider(makePersistentTunnelClient(tunnelCalls, configuration)); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const; + const provisioned = yield* provider.provision({ ...key, origin }); + const reconcile = provider.reconcileOrigin({ + ...key, + tunnelId: provisioned.runtime.tunnelId!, + origin, + endpoint: provisioned.endpoint, + }); + tunnelCalls.length = 0; + + expect(yield* reconcile).toBe("ready"); + expect(tunnelCalls.map((call) => call.operation)).toEqual(["get", "getConfiguration"]); + + configuration.current = { + ingress: [ + { hostname: expectedManagedHostname("env_ABC"), service: "http://127.0.0.1:4711" }, + { service: "http_status:404" }, + ], + }; + expect(yield* reconcile).toBe("ready"); + expect(configuration.current).toEqual({ + ingress: [ + { hostname: expectedManagedHostname("env_ABC"), service: "http://127.0.0.1:3773" }, + { service: "http_status:404" }, + ], + }); + + configuration.current = { + ingress: [ + { hostname: "*.t3code.test", service: "http://127.0.0.1:4711" }, + { hostname: expectedManagedHostname("env_ABC"), service: "http://127.0.0.1:3773" }, + { service: "http_status:404" }, + ], + }; + expect(yield* reconcile).toBe("ready"); + expect(configuration.current.ingress[0]).toEqual({ + hostname: expectedManagedHostname("env_ABC"), + service: "http://127.0.0.1:3773", + }); + + configuration.current = { + ingress: [ + { + hostname: expectedManagedHostname("env_ABC"), + path: "/api", + service: "http://127.0.0.1:3773", + }, + { service: "http_status:404" }, + ], + }; + expect(yield* reconcile).toBe("ready"); + expect(configuration.current.ingress[0]).toEqual({ + hostname: expectedManagedHostname("env_ABC"), + service: "http://127.0.0.1:3773", + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("rejects an active endpoint that does not match the allocation hostname", () => { const layer = layerProvider(makePersistentTunnelClient()); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 2c2c955abbbfa..188ad0e7b7148 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -5,7 +5,7 @@ import * as Arr from "effect/Array"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -216,6 +216,7 @@ const ManagedEndpointTunnelClientOperation = Schema.Literals([ "list", "create", "put-configuration", + "get-configuration", "get-token", "delete", ]); @@ -265,6 +266,16 @@ export class ManagedEndpointTunnelClient extends Context.Service< }>; }, ) => Effect.Effect; + readonly getConfiguration: (tunnelId: string) => Effect.Effect< + { + readonly ingress: ReadonlyArray<{ + readonly hostname?: string | null; + readonly path?: string | null; + readonly service: string; + }>; + }, + ManagedEndpointTunnelClientError + >; readonly getToken: ( tunnelId: string, ) => Effect.Effect; @@ -552,7 +563,7 @@ const ignoreNotFound = ( const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const tunnels = yield* ManagedEndpointTunnelClient; const dns = yield* ManagedEndpointDnsClient; const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; @@ -756,32 +767,53 @@ const make = Effect.gen(function* () { hostname: allocation.hostname, }); } + const verifyTunnel = (read: Effect.Effect) => + read.pipe( + Effect.asSome, + Effect.catchTags({ + ManagedEndpointTunnelClientError: (cause) => + isManagedEndpointNotFound(cause.cause) + ? Effect.succeedNone + : Effect.fail( + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "verify-tunnel", + tunnelId: input.tunnelId, + cause, + }), + ), + }), + ); // Released records retain tunnel ids; verify Cloudflare still has the tunnel before reconnecting. - const recorded = yield* tunnels.get(input.tunnelId).pipe( - Effect.asSome, - Effect.catchTags({ - ManagedEndpointTunnelClientError: (cause) => - isManagedEndpointNotFound(cause.cause) - ? Effect.succeedNone - : Effect.fail( - new ManagedEndpointProvisioningFailed({ - userId: input.userId, - environmentId: input.environmentId, - stage: "verify-tunnel", - tunnelId: input.tunnelId, - cause, - }), - ), - }), - ); + const recorded = yield* verifyTunnel(tunnels.get(input.tunnelId)); if (Option.isNone(recorded) || recorded.value.deletedAt) { return "recovery_required"; } + const originService = formatOriginService(input.origin); if ( allocation.origin?.localHttpHost === input.origin.localHttpHost && allocation.origin.localHttpPort === input.origin.localHttpPort ) { - return "ready"; + // Live ingress can drift from the recorded origin; only a matching rule skips the rewrite. + const configuration = yield* verifyTunnel(tunnels.getConfiguration(input.tunnelId)); + if (Option.isNone(configuration)) { + return "recovery_required"; + } + const hostname = normalizeHostname(allocation.hostname); + // Cloudflare routes by the first matching rule, so an earlier wildcard or catch-all wins. + const rule = configuration.value.ingress.find((entry) => { + if (entry.hostname == null) return true; + const candidate = normalizeHostname(entry.hostname); + return ( + candidate === hostname || + (candidate.startsWith("*.") && hostname.endsWith(candidate.slice(1))) + ); + }); + // A path-restricted rule leaves other paths to later rules, so it cannot prove routing. + if (rule?.service === originService && !rule.path) { + return "ready"; + } } const updated = yield* allocations @@ -795,10 +827,7 @@ const make = Effect.gen(function* () { tunnels .putConfiguration(input.tunnelId, { ingress: [ - { - hostname: allocation.hostname, - service: formatOriginService(input.origin), - }, + { hostname: allocation.hostname, service: originService }, { service: "http_status:404" }, ], }) @@ -1173,9 +1202,9 @@ const make = Effect.gen(function* () { }); } const cf = yield* requireCloudflareSettings(config, input); - const environmentHash = yield* crypto + const environmentHash = yield* digest .digest( - "SHA-256", + Digest.sha256, new TextEncoder().encode( managedEndpointDigestInput(cf.namespace, input.userId, input.environmentId), ), @@ -1606,6 +1635,22 @@ export const layerCloudflareBindings = ( }), ), ), + getConfiguration: (tunnelId) => + gate(tunnelClient.getConfiguration(tunnelId)).pipe( + Effect.map(({ config }) => ({ ingress: config?.ingress ?? [] })), + // Cloudflare reports a tunnel without remote configuration as code 1055, not 404. + Effect.catchTags({ + TunnelConfigurationNotFound: () => Effect.succeed({ ingress: [] }), + }), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "get-configuration", + tunnelId, + cause, + }), + ), + ), getToken: (tunnelId) => gate(tunnelClient.getToken(tunnelId)).pipe( Effect.mapError( diff --git a/infra/relay/src/environments/ManagedEndpointReaper.test.ts b/infra/relay/src/environments/ManagedEndpointReaper.test.ts index d2a9bb7f6c3b0..5ce24e6b7e5c1 100644 --- a/infra/relay/src/environments/ManagedEndpointReaper.test.ts +++ b/infra/relay/src/environments/ManagedEndpointReaper.test.ts @@ -140,6 +140,7 @@ function harness(input?: { }), create: () => Effect.die("unused"), putConfiguration: () => Effect.die("unused"), + getConfiguration: () => Effect.die("unused"), getToken: () => Effect.die("unused"), delete: (tunnelId) => input?.failAllDeletes === true || diff --git a/infra/relay/src/hooks/HookForwarder.ts b/infra/relay/src/hooks/HookForwarder.ts index 034a953863c26..d93c6b4074672 100644 --- a/infra/relay/src/hooks/HookForwarder.ts +++ b/infra/relay/src/hooks/HookForwarder.ts @@ -1,6 +1,6 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -219,7 +219,7 @@ const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const rateLimiter = yield* HookRateLimiter; const inbox = yield* HookInbox.HookInbox; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const signDeliveryProof = Effect.fn("HookForwarder.signDeliveryProof")( function* (input: { @@ -318,14 +318,14 @@ const make = Effect.gen(function* () { // Reuse one delivery ID so inbox retries cannot rerun requests that reached the environment before timeout. yield* Effect.annotateCurrentSpan({ "relay.hook.body_bytes": body.success.byteLength }); - const deliveryId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const deliveryId = yield* random.uuidV4(); // Sign once before storing held requests so the inbox needs no signing key. const proof = yield* signDeliveryProof({ environmentId: endpoint.environmentId, deliveryId, receivedAt, hookId: parsed.hookId, - jti: yield* crypto.randomUUIDv4().pipe(Effect.orDie), + jti: yield* random.uuidV4(), }); const hook = { id: deliveryId, diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 390f3c18f785f..902ace8c56d92 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -1,6 +1,6 @@ import { createClerkClient, verifyToken } from "@clerk/backend"; import { sql as drizzleSql } from "drizzle-orm"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -819,7 +819,7 @@ export const layerClientApi = HttpApiBuilder.group( "client", Effect.fnUntraced(function* (handlers) { const config = yield* RelayConfiguration.RelayConfiguration; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const relayTokens = yield* RelayTokens.RelayTokens; const linker = yield* EnvironmentLinker.EnvironmentLinker; const links = yield* EnvironmentLinks.EnvironmentLinks; @@ -945,9 +945,7 @@ export const layerClientApi = HttpApiBuilder.group( const { userId } = yield* RelayClientPrincipal; const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { minutes: 5 }); - const jti = yield* crypto - .randomUUIDv4() - .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))); + const jti = yield* random.uuidV4(); const challenge = yield* relayTokens .issueLinkChallenge({ userId, @@ -1045,7 +1043,7 @@ export const layerTokenApi = HttpApiBuilder.group( "token", Effect.fnUntraced(function* (handlers) { const config = yield* RelayConfiguration.RelayConfiguration; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const dpopProofs = yield* DpopProofs.DpopProofReplay; const relayTokens = yield* RelayTokens.RelayTokens; return handlers.handle( @@ -1077,9 +1075,7 @@ export const layerTokenApi = HttpApiBuilder.group( ); const now = yield* DateTime.now; const expiresAt = DateTime.addDuration(now, RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL); - const jti = yield* crypto - .randomUUIDv4() - .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))); + const jti = yield* random.uuidV4(); return { access_token: yield* relayTokens .issueDpopAccessToken({ diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index d59fc2f6c9189..08b5cf1f7ab30 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -5,7 +5,8 @@ import * as Drizzle from "@alchemy.run/drizzle/Postgres"; import * as Config from "effect/Config"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -72,18 +73,7 @@ import * as HookInbox from "./hooks/HookInbox.ts"; import * as HookInboxObject from "./hooks/HookInboxObject.ts"; import * as MobileTelemetry from "./mobileTelemetry/MobileTelemetry.ts"; -const layerWebcrypto = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)), - digest: (algorithm, data) => - Effect.promise(async () => { - const input = new Uint8Array(data.length); - input.set(data); - return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer)); - }), - }), -); +const layerWebcrypto = Layer.mergeAll(SecureRandom.layerWebCrypto, Digest.layerWebCrypto); const layerHttpPlatformNotSupported = Layer.succeed(HttpPlatform.HttpPlatform, { platform: "web", diff --git a/infra/task-cache/scripts/manage.ts b/infra/task-cache/scripts/manage.ts index 212d633cadd1e..8be04a4799149 100644 --- a/infra/task-cache/scripts/manage.ts +++ b/infra/task-cache/scripts/manage.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -67,6 +68,10 @@ const command = Command.make("task-cache").pipe( ); Command.run(command, { version: "1.0.0" }).pipe( - Effect.provide(WriterRegistry.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), NodeRuntime.runMain, ); diff --git a/infra/task-cache/src/WriterRegistry.test.ts b/infra/task-cache/src/WriterRegistry.test.ts index 449bdf5b13270..a754a407c5a88 100644 --- a/infra/task-cache/src/WriterRegistry.test.ts +++ b/infra/task-cache/src/WriterRegistry.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Config from "effect/Config"; @@ -29,7 +30,7 @@ it.effect("loads the private writer registry as deployment configuration", () => Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), ); expect(JSON.parse(Redacted.value(keys))).toEqual({ maria: "digest" }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("uses explicit deployment credentials without requiring a local registry", () => @@ -42,7 +43,7 @@ it.effect("uses explicit deployment credentials without requiring a local regist ), ); expect(JSON.parse(Redacted.value(keys))).toEqual({ theo: "digest" }); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("fails instead of deploying an empty registry when credentials are missing", () => @@ -54,7 +55,7 @@ it.effect("fails instead of deploying an empty registry when credentials are mis Effect.exit, ); expect(Exit.isFailure(result)).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.live("rejects reserved writer names without modifying credential stores", () => @@ -85,7 +86,7 @@ it.live("rejects reserved writer names without modifying credential stores", () expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(writers); expect(yield* fs.readFileString(`${state}/credentials.json`)).toBe(credentials); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("writes credentials for the supplied deployed worker endpoint", () => @@ -106,7 +107,7 @@ it.effect("writes credentials for the supplied deployed worker endpoint", () => const credential = JSON.parse(yield* fs.readFileString(file)); expect(credential.endpoint).toBe(endpoint); expect(credential.name).toBe("maria"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("refuses a concurrent registry update without changing committed credentials", () => @@ -128,7 +129,7 @@ it.effect("refuses a concurrent registry update without changing committed crede expect(Exit.isFailure(result)).toBe(true); expect(yield* fs.readFileString(`${state}/writers.json`)).toBe('{"maria":"digest"}'); expect(yield* fs.exists(`${state}/theo.json`)).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("keeps the committed registry intact if a replacement write fails", () => @@ -160,7 +161,7 @@ it.effect("keeps the committed registry intact if a replacement write fails", () expect(Exit.isFailure(result)).toBe(true); expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(original); expect(yield* fs.exists(`${state}/writers.lock`)).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("preserves filesystem errors when acquiring the registry lock", () => @@ -191,7 +192,7 @@ it.effect("preserves filesystem errors when acquiring the registry lock", () => Effect.flip, ); expect(error).toBe(denied); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("refuses to initialize a missing writer registry implicitly", () => @@ -209,7 +210,7 @@ it.effect("refuses to initialize a missing writer registry implicitly", () => ); expect(Exit.isFailure(result)).toBe(true); expect(yield* fs.exists(`${home}/.config/t3-task-cache/writers.json`)).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("keeps existing authorization if credential publication fails", () => @@ -243,5 +244,5 @@ it.effect("keeps existing authorization if credential publication fails", () => ); expect(Exit.isFailure(result)).toBe(true); expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(original); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/infra/task-cache/src/WriterRegistry.ts b/infra/task-cache/src/WriterRegistry.ts index 0032f66e292d1..05411dce3cfa7 100644 --- a/infra/task-cache/src/WriterRegistry.ts +++ b/infra/task-cache/src/WriterRegistry.ts @@ -1,6 +1,7 @@ import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * 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 type * as PlatformError from "effect/PlatformError"; @@ -47,7 +48,8 @@ export class WriterRegistry extends Context.Service< const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; const state = yield* stateDirectory; const readWriters = Effect.fn("TaskCache.readWriters")(function* () { return yield* decodeWriters(yield* fs.readFileString(`${state}/writers.json`)); @@ -105,10 +107,10 @@ const make = Effect.gen(function* () { "Writer registry is missing. Copy the existing private registry from its administrator; use --new-registry only when creating a new cache.", }); const writers = { ...(exists || revoke ? yield* readWriters() : {}) }; - const token = hex(yield* crypto.randomBytes(32)); + const token = hex(yield* random.randomBytes(32)); if (revoke) delete writers[name]; else { - writers[name] = hex(yield* crypto.digest("SHA-256", new TextEncoder().encode(token))); + writers[name] = hex(yield* digest.digest(Digest.sha256, new TextEncoder().encode(token))); } const entries: Array = []; if (!revoke && endpoint) diff --git a/knip.jsonc b/knip.jsonc index f7550eaf4ed26..12a989eb51be3 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -20,7 +20,7 @@ // Knip loads its preprocessor through a CLI option; native verification and // worktree setup (from t3.json) run directly. release-desktop.yml bundles the // Windows verify script with esbuild through a path knip does not trace, - // electron-builder loads the macOS signer by path, and mobile-e2e.ts runs + // electron-builder loads the macOS signer and the Linux afterPack hook by path, and mobile-e2e.ts runs // agent-device's CLI by path, and mobile-behavior.ts spawns its runner by path. // release-desktop.yml runs the desktop launch check through xvfb-run, which knip // does not trace. @@ -28,6 +28,7 @@ // These fixture scripts are injected into a browser page by path. "fixtures/*/*.js", "knip-schemas.ts", + "linux-after-pack.ts", "mobile-behavior/run.ts", "mobile-native-client.ts", "setup-worktree.ts", diff --git a/package.json b/package.json index 80cf0ceef04c7..6d0d139ad8815 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "report:renders": "node scripts/render-metrics.ts", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-kiro --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "mobile:install": "node scripts/mobile-native-client.ts ensure", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 94f5dcbdd0c45..2ac4086f21c37 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -565,6 +565,7 @@ "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-fx": "workspace:*", "@t3tools/provider-grok": "workspace:*", + "@t3tools/provider-kiro": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 120784b7396ad..a6ee1b2827962 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -9,6 +9,7 @@ import { mapManagedRelayError, mapRemoteEnvironmentError } from "./errors.ts"; import { AVAILABLE_CONNECTION_STATE, ConnectionBlockedError, + ConnectionTransientError, type ConnectionAttemptError, type SupervisorConnectionState, } from "./model.ts"; @@ -150,6 +151,30 @@ describe("presentConnectionDiagnosis", () => { expect(origin?.hops.map((hop) => hop.hop)).toEqual(["device", "relay", "tunnel", "server"]); }); + it("blames this device, not the host, while its own T3 Connect sign-in loads", () => { + const failure = new ConnectionTransientError({ + reason: "session-pending", + detail: "Waiting for the T3 Connect sign-in to load.", + }); + const diagnosis = diagnose(backoff(failure)); + expect(diagnosis?.headline).not.toContain("did not answer"); + expect(diagnosis?.hops.find((hop) => hop.result === "failed")?.hop).toBe("device"); + expect(diagnosis?.steps.join(" ")).not.toContain("Mac mini"); + expect(diagnosis?.moreInfoUrl).toContain("sign-in-account-or-link-problems"); + // A host with a LAN route as well uses the direct route, still authorized through T3 Connect. + expect(diagnose(backoff(failure), "direct")?.moreInfoUrl).toContain( + "sign-in-account-or-link-problems", + ); + }); + + it("does not report hops as working when the failure names none", () => { + const failure = new ConnectionBlockedError({ + reason: "authentication", + detail: "Your cloud sign-in changed. Sign in again to authorize the environment.", + }); + expect(results(backoff(failure))).not.toContainEqual(expect.stringMatching(/:ok$/)); + }); + it("keeps the relay health status out of a failure that has its own cause", () => { const failure = mapRemoteEnvironmentError( new RemoteEnvironmentAuthFetchError({ message: "Failed to fetch", cause: new TypeError() }), diff --git a/packages/client-runtime/src/connection/diagnosis.ts b/packages/client-runtime/src/connection/diagnosis.ts index 592150b640d4f..688e0121cc154 100644 --- a/packages/client-runtime/src/connection/diagnosis.ts +++ b/packages/client-runtime/src/connection/diagnosis.ts @@ -26,7 +26,7 @@ export interface ConnectionDiagnosis { readonly hops: ReadonlyArray; readonly steps: ReadonlyArray; readonly facts: ReadonlyArray; - /** The troubleshooting guide section for this failure. Null for direct routes. */ + /** The troubleshooting guide section for this failure. Null for direct routes, except a pending T3 Connect sign-in. */ readonly moreInfoUrl: string | null; } @@ -46,7 +46,15 @@ export interface ConnectionDiagnosisInput { interface Located { readonly hop: ConnectionHop; - readonly cause: ConnectionFailureCause | "offline" | "outdated" | "released" | null; + readonly cause: + | ConnectionFailureCause + | "offline" + | "outdated" + | "released" + | "session_pending" + | null; + /** The failure named no hop, so the hops before this one were never probed. */ + readonly guessed?: true; } const NOT_ANSWERING: ReadonlySet = new Set([ @@ -62,6 +70,9 @@ function locate(input: ConnectionDiagnosisInput): Located | null { if (failure?._tag === "ConnectionBlockedError" && failure.serverUpdateRequired) { return { hop: "server", cause: "outdated" }; } + if (failure?.reason === "session-pending") { + return { hop: "device", cause: "session_pending" }; + } if (failure?.hop !== undefined) { return { hop: failure.hop, cause: failure.failureCause ?? null }; } @@ -71,7 +82,11 @@ function locate(input: ConnectionDiagnosisInput): Located | null { failure.reason === "timeout" || failure.reason === "transport"; const via = failure.via ?? input.via; - return { hop: unreachable && via === "relay" ? "tunnel" : "server", cause: null }; + return { + hop: unreachable && via === "relay" ? "tunnel" : "server", + cause: null, + guessed: true, + }; } const status = input.relayStatus; if (status?.status !== "offline") return null; @@ -84,6 +99,8 @@ function headline(host: string, located: Located, failure: ConnectionAttemptErro switch (located.cause) { case "offline": return "This device is offline"; + case "session_pending": + return "This device is still loading its T3 Connect sign-in"; case "outdated": return `${host} runs an older T3 Code`; case "clock_skew": @@ -141,6 +158,11 @@ function steps(host: string, located: Located, failure: ConnectionAttemptError | switch (located.cause) { case "offline": return ["Connect this device to the internet. T3 Code reconnects on its own."]; + case "session_pending": + return [ + "Wait a moment. T3 Code retries on its own.", + "If it keeps waiting, reload or restart T3 Code on this device.", + ]; case "outdated": return [`Update T3 Code on ${host}.`]; case "clock_skew": @@ -220,6 +242,7 @@ function helpSection( return "the-host-runs-an-older-t3-code"; case "clock_skew": return "the-clock-is-off"; + case "session_pending": case "not_linked": case "tunnel_not_enabled": return "sign-in-account-or-link-problems"; @@ -300,7 +323,12 @@ export function presentConnectionDiagnosis( const hops = order.map((hop, index): ConnectionDiagnosisHop => ({ hop, label: hopLabel(hop, host), - result: index < failedIndex ? "ok" : index === failedIndex ? "failed" : "unchecked", + result: + index === failedIndex + ? "failed" + : index < failedIndex && located.guessed !== true + ? "ok" + : "unchecked", })); // The relay health check only explains the state when no attempt has failed yet. @@ -339,7 +367,10 @@ export function presentConnectionDiagnosis( : []), ], facts, - moreInfoUrl: relayPath ? t3ConnectTroubleshootingUrl(helpSection(located, failure)) : null, + moreInfoUrl: + relayPath || located.cause === "session_pending" + ? t3ConnectTroubleshootingUrl(helpSection(located, failure)) + : null, }; } diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index db8450f98f72c..3a03773da7af5 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -418,7 +418,13 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ? Effect.die(new Error("Config is not used by registry tests.")) : Effect.succeed({ directEndpoints: options.directEndpoints } as ServerConfig), subscribeServerConfig: () => - Stream.die(new Error("Config is not used by registry tests.")), + options?.directEndpoints === undefined + ? Stream.never + : Stream.make({ + version: 1 as const, + type: "snapshot" as const, + config: { directEndpoints: options.directEndpoints } as ServerConfig, + }), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 1b492de07b669..0b238bce2fa35 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1,4 +1,9 @@ -import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import { + AuthStandardClientScopes, + EnvironmentId, + type ServerConfig, + type ServerConfigStreamEvent, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -85,7 +90,7 @@ const LAN_TARGET = new BearerConnectionTarget({ label: TARGET.label, connectionId: "bearer:lan", }); -const LAN_ROUTE: ConnectionRoute = { +const LAN_ROUTE = { target: LAN_TARGET, profile: Option.some( new BearerConnectionProfile({ @@ -96,7 +101,7 @@ const LAN_ROUTE: ConnectionRoute = { wsBaseUrl: "ws://192.168.1.10:3773/", }), ), -}; +} satisfies ConnectionRoute; const LAN_THEN_RELAY_ENTRY: ConnectionCatalogEntry = { target: LAN_ROUTE.target, profile: LAN_ROUTE.profile, @@ -151,6 +156,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ) => Effect.Effect; readonly ready?: (attempt: number) => Effect.Effect; readonly probe?: (attempt: number) => Effect.Effect; + readonly configEvents?: Stream.Stream | undefined; readonly initialConfig?: (attempt: number) => Effect.Effect; readonly checkRoute?: (route: ConnectionRoute) => Effect.Effect; readonly checkHost?: (prepared: PreparedConnection) => Effect.Effect; @@ -207,7 +213,13 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: initialConfig: options?.initialConfig?.(attempt) ?? Effect.die(new Error("Initial config is not used by supervisor tests.")), - subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input), + subscribeServerConfig: () => + options?.configEvents ?? + Stream.fromEffect( + (options?.initialConfig?.(attempt) ?? Effect.never).pipe(Effect.orDie), + ).pipe( + Stream.map((config) => ({ version: 1 as const, type: "snapshot" as const, config })), + ), ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, inboundFrames: options?.inboundFrames ?? Effect.succeed(0), @@ -318,7 +330,7 @@ describe("EnvironmentSupervisor", () => { it.effect("waits while offline and connects immediately when the network returns", () => Effect.gen(function* () { const harness = yield* makeHarness({ networkStatus: "offline" }); - const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -340,6 +352,54 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("connects to the primary environment while the device reports offline", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ networkStatus: "offline" }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + const settled = yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" || state.phase === "offline", + ); + + expect(settled).toMatchObject({ + phase: "connected", + network: "unknown", + attempt: 1, + lastFailure: null, + }); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + }), + ); + + it.effect( + "keeps a primary attempt through an offline report and restarts it when the network returns", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 + ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(started); + yield* harness.setNetworkStatus("offline"); + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + yield* harness.setNetworkStatus("online"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("keeps the latest failure visible throughout the next connection attempt", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -757,6 +817,29 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("checks the primary environment's socket when the device reports offline", () => + Effect.gen(function* () { + const harness = yield* makeSilentSocketHarness(); + const target = new PrimaryConnectionTarget({ + ...TARGET, + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }); + const supervisor = yield* EnvironmentSupervisor.make( + { ...TARGET_ENTRY, target }, + { initiallyDesired: true }, + ).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.setNetworkStatus("offline"); + yield* TestClock.adjust("500 millis"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("replaces a remote socket after a long resume while the network reports offline", () => Effect.gen(function* () { const harness = yield* makeSilentSocketHarness(); @@ -965,7 +1048,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), }); - const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(LAN_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -1668,7 +1751,7 @@ describe("EnvironmentSupervisor routes", () => { }), ); - it.effect("upgrades a healthy relay when a discovered LAN route answers", () => + it.effect("keeps upgrading after the active discovered LAN route is withdrawn", () => Effect.gen(function* () { const harness = yield* makeHarness({ checkRoute: (route) => @@ -1704,6 +1787,19 @@ describe("EnvironmentSupervisor routes", () => { ).toBe(`learned:${TARGET.environmentId}:http://192.168.1.22:3773@bearer:lan`); yield* supervisor.setDiscoveredRoutes([]); expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + yield* supervisor.setDiscoveredRoutes( + discoveredRoutes(LAN_THEN_RELAY_ENTRY, ["http://192.168.1.23:3773/"]), + ); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 3, + ); + expect( + connectionRouteId( + Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target, + ), + ).toBe(`learned:${TARGET.environmentId}:http://192.168.1.23:3773@bearer:lan`); + expect(yield* Ref.get(harness.releaseCount)).toBe(2); }), ); @@ -1915,62 +2011,134 @@ describe("EnvironmentSupervisor routes", () => { }), ); - it.effect("learns the LAN address over T3 Connect and moves to it", () => - Effect.gen(function* () { - const relayEntry: ConnectionCatalogEntry = { - target: RELAY_TARGET, - profile: Option.none(), - enabled: true, - }; - const lanAddress = yield* Ref.make("http://192.168.1.10:3773/"); - const learned = yield* Ref.make>([]); - const harness = yield* makeHarness({ - checkRoute: (route) => - route.target._tag === "BearerConnectionTarget" - ? Effect.succeed("answered") - : Effect.succeed("unchecked"), - prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), - initialConfig: () => - Ref.get(lanAddress).pipe( - Effect.map((httpBaseUrl): Pick => ({ - directEndpoints: [{ kind: "lan", httpBaseUrl }], - })), - Effect.map((config) => config as ServerConfig), - ), - }); - const current = yield* Ref.make(relayEntry); - const supervisor = yield* EnvironmentSupervisor.make(relayEntry, { - initiallyDesired: true, - learnRoutes: ({ activeRoute, reported }) => - Effect.gen(function* () { - const entry = yield* Ref.get(current); - const routes = mergeLearnedRoutes({ - entry, - activeRoute, - reported, - allowInsecure: true, - }); - if (routes === null) return Option.none(); - const next = entryWithRoutes(entry, routes); - yield* Ref.set(current, next); - yield* Ref.update(learned, (all) => [ - ...all, - ...routes.filter(isLearned).map((route) => connectionRouteId(route.target)), - ]); - return Option.some(next); - }), - }).pipe(Effect.provide(harness.dependencies)); + it.effect.each(["initial", "late", "withdrawn", "withdrawn-upgrade", "omitted"] as const)( + "learns the %s direct endpoint over T3 Connect", + (discovery) => + Effect.gen(function* () { + const relayEntry: ConnectionCatalogEntry = + discovery.startsWith("withdrawn") || discovery === "omitted" + ? { + target: LAN_ROUTE.target, + profile: Option.some( + new BearerConnectionProfile({ + ...Option.getOrThrow(LAN_ROUTE.profile), + ...(discovery === "withdrawn-upgrade" + ? { + httpBaseUrl: "https://old.example.test/", + wsBaseUrl: "wss://old.example.test/", + } + : {}), + learned: true, + authorization: "t3-connect", + }), + ), + alternateRoutes: [{ target: RELAY_TARGET, profile: Option.none() }], + enabled: true, + } + : { + target: RELAY_TARGET, + profile: Option.none(), + enabled: true, + }; + const initialProcessed = yield* Deferred.make(); + const discoveryProcessed = yield* Deferred.make(); + const discoveryStarted = yield* Deferred.make(); + const finishDiscovery = yield* Deferred.make(); + const lateAddress = "https://fixture.tail1234.ts.net/"; + const learned = yield* Ref.make>([]); + const harness = yield* makeHarness({ + configEvents: + discovery !== "initial" + ? Stream.concat( + Stream.make({ + version: 1 as const, + type: "snapshot" as const, + config: (discovery === "omitted" + ? {} + : { directEndpoints: [] }) as unknown as ServerConfig, + }).pipe(Stream.ensuring(Deferred.succeed(initialProcessed, undefined))), + Stream.fromEffect( + Deferred.await(finishDiscovery).pipe( + Effect.as({ + version: 1 as const, + type: "snapshot" as const, + config: { + directEndpoints: [{ kind: "tailnet", httpBaseUrl: lateAddress }], + } as unknown as ServerConfig, + }), + ), + ), + ).pipe(Stream.ensuring(Deferred.succeed(discoveryProcessed, undefined))) + : undefined, + checkRoute: (route) => + route.target._tag === "BearerConnectionTarget" + ? Effect.succeed("answered") + : Effect.succeed("unchecked"), + prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), + initialConfig: () => + Effect.succeed({ + directEndpoints: + discovery !== "initial" + ? [] + : [{ kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }], + } as unknown as ServerConfig), + }); + const current = yield* Ref.make(relayEntry); + const supervisor = yield* EnvironmentSupervisor.make(relayEntry, { + initiallyDesired: true, + learnRoutes: ({ activeRoute, reported }) => + Effect.gen(function* () { + yield* Deferred.succeed(discoveryStarted, undefined); + const entry = yield* Ref.get(current); + const routes = mergeLearnedRoutes({ + entry, + activeRoute, + reported, + allowInsecure: true, + }); + if (routes === null) return Option.none(); + const next = entryWithRoutes(entry, routes); + yield* Ref.set(current, next); + yield* Ref.update(learned, (all) => [ + ...all, + ...routes.filter(isLearned).map((route) => connectionRouteId(route.target)), + ]); + return Option.some(next); + }), + }).pipe(Effect.provide(harness.dependencies)); - yield* awaitState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 2, - ); - expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( - "BearerConnectionTarget", - ); - expect(yield* Ref.get(learned)).toEqual([ - `learned:${TARGET.environmentId}:http://192.168.1.10:3773`, - ]); - }), + if (discovery === "omitted") { + yield* Deferred.await(initialProcessed); + expect(yield* Ref.get(current)).toEqual(relayEntry); + expect(yield* Ref.get(learned)).toEqual([]); + } else { + yield* Deferred.await(discoveryStarted); + } + yield* Deferred.succeed(finishDiscovery, undefined); + if (discovery === "withdrawn-upgrade") { + yield* Deferred.await(discoveryProcessed); + yield* TestClock.adjust("60 seconds"); + expect((yield* SubscriptionRef.get(supervisor.state)).generation).toBe(2); + return; + } + if (discovery === "withdrawn" || discovery === "omitted") { + yield* Deferred.await(discoveryProcessed); + expect(yield* Ref.get(learned)).toContain( + `learned:${TARGET.environmentId}:https://fixture.tail1234.ts.net`, + ); + expect((yield* SubscriptionRef.get(supervisor.state)).generation).toBe(1); + return; + } + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( + "BearerConnectionTarget", + ); + expect(yield* Ref.get(learned)).toEqual([ + `learned:${TARGET.environmentId}:${discovery !== "initial" ? "https://fixture.tail1234.ts.net" : "http://192.168.1.10:3773"}`, + ]); + }), ); }); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 7b138006a1481..070cec7c765c3 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -93,6 +93,10 @@ interface PendingRetryTrace { readonly reason: ConnectionAttemptError["reason"]; } +type ConnectedLease = ConnectionDriver.EnvironmentConnectionLease & { + readonly activeRoute: ConnectionRoute | undefined; +}; + interface TracedAttemptFailure { readonly error: ConnectionAttemptError; readonly attemptSpan: Option.Option; @@ -291,9 +295,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const connectivity = yield* Connectivity.Connectivity; const driver = yield* ConnectionDriver.ConnectionDriver; const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + // The primary environment served this client or runs beside it on loopback, + // so the device reporting offline (often wrongly) never parks it. The report + // still probes its live session, like any other network change. + const targetNetwork = (network: NetworkStatus): NetworkStatus => + network === "offline" && target._tag === "PrimaryConnectionTarget" ? "unknown" : network; const initialIntent: SupervisorIntent = { desired: options?.initiallyDesired ?? false, - network: yield* connectivity.status, + network: targetNetwork(yield* connectivity.status), }; const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); @@ -308,6 +317,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); const session = yield* SubscriptionRef.make>(Option.none()); const prepared = yield* SubscriptionRef.make>(Option.none()); + const activeLease = yield* Ref.make(Option.none()); // Learned routes arrive while a session is live, so the route list is read // fresh by each attempt and check instead of being fixed at creation. const currentEntry = yield* Ref.make(entry); @@ -350,39 +360,46 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const learnRoutesFrom = Effect.fnUntraced(function* ( learn: NonNullable, - lease: ConnectionDriver.EnvironmentConnectionLease, + lease: ConnectedLease, ) { - const config = yield* lease.session.initialConfig.pipe(Effect.option); - const reported = Option.getOrUndefined(config)?.directEndpoints; - if (reported === undefined) return; - const activeId = connectionRouteId(lease.prepared.target); - const activeRoute = (yield* currentRoutes).find( - (route) => connectionRouteId(route.target) === activeId, - ); + const activeRoute = lease.activeRoute; if (activeRoute === undefined) return; - const discoveryOrigins = (yield* Ref.get(discovered)).flatMap((route) => { - const url = routeHttpBaseUrl(route); - return url === null ? [] : [new URL(url).origin]; - }); - const updated = yield* learn({ activeRoute, reported, discoveryOrigins }); - if (Option.isSome(updated)) { - yield* Ref.set(currentEntry, updated.value); - yield* requestBetterRouteCheck(lease); - } + yield* lease.session.subscribeServerConfig().pipe( + Stream.filter((event) => event.type === "snapshot"), + Stream.map((event) => event.config.directEndpoints), + Stream.filter((reported) => reported !== undefined), + Stream.changesWith((previous, next) => JSON.stringify(previous) === JSON.stringify(next)), + Stream.runForEach((reported) => + Effect.gen(function* () { + const discoveryOrigins = (yield* Ref.get(discovered)).flatMap((route) => { + const url = routeHttpBaseUrl(route); + return url === null ? [] : [new URL(url).origin]; + }); + const updated = yield* learn({ activeRoute, reported, discoveryOrigins }); + if (Option.isSome(updated)) { + yield* Ref.set(currentEntry, updated.value); + yield* requestBetterRouteCheck(lease); + } + }), + ), + Effect.ignoreCause({ log: true }), + ); }); - const routeIndex = (lease: ConnectionDriver.EnvironmentConnectionLease) => + const routeIndex = (lease: ConnectedLease) => currentRoutes.pipe( - Effect.map((routes) => - routes.findIndex( - (route) => connectionRouteId(route.target) === connectionRouteId(lease.prepared.target), - ), - ), + Effect.map((routes) => { + const activeId = connectionRouteId(lease.prepared.target); + const index = routes.findIndex((route) => connectionRouteId(route.target) === activeId); + return index === -1 && lease.activeRoute !== undefined + ? insertRoute(routes, lease.activeRoute).findIndex( + (route) => connectionRouteId(route.target) === activeId, + ) + : index; + }), ); - const checkBetterRoutes = Effect.fnUntraced(function* ( - lease: ConnectionDriver.EnvironmentConnectionLease, - ) { + const checkBetterRoutes = Effect.fnUntraced(function* (lease: ConnectedLease) { const latest = yield* effectiveEntry; const generation = yield* Ref.get(discoveryGeneration); const current = yield* routeIndex(lease); @@ -427,8 +444,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }); }); - const betterRouteChecks = yield* Queue.dropping(1); - const requestBetterRouteCheck = (lease: ConnectionDriver.EnvironmentConnectionLease) => + const betterRouteChecks = yield* Queue.dropping(1); + const requestBetterRouteCheck = (lease: ConnectedLease) => routeIndex(lease).pipe( Effect.flatMap((index) => index > 0 ? Queue.offer(betterRouteChecks, lease).pipe(Effect.asVoid) : Effect.void, @@ -436,7 +453,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); const clearLease = Effect.all( - [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())], + [ + Ref.set(activeLease, Option.none()), + SubscriptionRef.set(session, Option.none()), + SubscriptionRef.set(prepared, Option.none()), + ], { discard: true }, ); @@ -587,6 +608,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }); const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () { + // A primary attempt keeps running through an offline report, so the + // network returning restarts it in case it stalled meanwhile. + let deviceOffline = (yield* connectivity.status) === "offline"; for (;;) { const next = yield* Queue.take(signals); switch (next._tag) { @@ -594,8 +618,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( case "RetryRequested": return false; case "NetworkChanged": - if (next.network === "offline") { - return false; + if (target._tag !== "PrimaryConnectionTarget") { + if (next.network === "offline") { + return false; + } + } else if (next.network === "offline") { + deviceOffline = true; + } else if (deviceOffline) { + return true; } break; case "ConnectRequested": @@ -695,7 +725,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( // event, and "alive" once the socket delivers a frame. A slow or // unreachable host never ends it. const awaitSilentSocket = Effect.fn("EnvironmentSupervisor.awaitSilentSocket")(function* ( - lease: ConnectionDriver.EnvironmentConnectionLease, + lease: ConnectedLease, routeKind: ConnectionRouteKind | undefined, ) { const startedAt = yield* Clock.monotonicTimeNanos; @@ -728,9 +758,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( // all probe the live session instead of replacing it, so a healthy socket is // not torn down (the offline report is often wrong, for example for a // loopback server). - const monitorConnectedLease = Effect.fnUntraced(function* ( - lease: ConnectionDriver.EnvironmentConnectionLease, - ) { + const monitorConnectedLease = Effect.fnUntraced(function* (lease: ConnectedLease) { // A probe answers an explicit retry here, so the retry must not also reset // the backoff of a later, unrelated failure. const takeSignal = Queue.take(signals).pipe( @@ -968,11 +996,19 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } satisfies AttemptOutcome; } + const connectedLease: ConnectedLease = { + ...active.lease, + activeRoute: (yield* currentRoutes).find( + (route) => + connectionRouteId(route.target) === connectionRouteId(active.lease.prepared.target), + ), + }; const connectedAt = yield* Clock.currentTimeMillis; - yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); - yield* SubscriptionRef.set(session, Option.some(active.lease.session)); + yield* SubscriptionRef.set(prepared, Option.some(connectedLease.prepared)); + yield* SubscriptionRef.set(session, Option.some(connectedLease.session)); + yield* Ref.set(activeLease, Option.some(connectedLease)); if (options?.learnRoutes !== undefined) { - yield* learnRoutesFrom(options.learnRoutes, active.lease).pipe(Effect.forkScoped); + yield* learnRoutesFrom(options.learnRoutes, connectedLease).pipe(Effect.forkScoped); } yield* setState({ desired: true, @@ -986,13 +1022,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }); const connectedExit = yield* Effect.raceFirst( - active.lease.session.closed.pipe( + connectedLease.session.closed.pipe( Effect.mapError((error): TracedAttemptFailure => ({ error, attemptSpan: active.attemptSpan, })), ), - monitorConnectedLease(active.lease).pipe( + monitorConnectedLease(connectedLease).pipe( Effect.mapError((error): TracedAttemptFailure => ({ error, attemptSpan: active.attemptSpan, @@ -1182,9 +1218,12 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* connectivity.changes.pipe( Stream.runForEach((network) => - Ref.modify(intent, (current) => - current.network === network ? [false, current] : ([true, { ...current, network }] as const), - ).pipe( + Ref.modify(intent, (current) => { + const next = targetNetwork(network); + return current.network === next + ? [false, current] + : ([true, { ...current, network: next }] as const); + }).pipe( Effect.flatMap((changed) => changed ? (network === "offline" ? Effect.void : forgetSilentRoutes).pipe( @@ -1238,13 +1277,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( if (ids(previous) === ids(routes)) return; yield* Ref.update(discoveryGeneration, (value) => value + 1); yield* Ref.set(discovered, routes); - const currentSession = yield* SubscriptionRef.get(session); - const currentPrepared = yield* SubscriptionRef.get(prepared); - if (Option.isSome(currentSession) && Option.isSome(currentPrepared)) { - yield* requestBetterRouteCheck({ - session: currentSession.value, - prepared: currentPrepared.value, - }); + const currentLease = yield* Ref.get(activeLease); + if (Option.isSome(currentLease)) { + yield* requestBetterRouteCheck(currentLease.value); } else if ((yield* Ref.get(intent)).desired) { yield* signal({ _tag: "RetryRequested" }); } diff --git a/packages/client-runtime/src/handoff.test.ts b/packages/client-runtime/src/handoff.test.ts index c2edbbe2b1bea..dde5e0c5904bd 100644 --- a/packages/client-runtime/src/handoff.test.ts +++ b/packages/client-runtime/src/handoff.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { resolveHandoffEndpoints, + resolveModelSwitchChange, resolvePendingHandoff, type HandoffTimelineRun, } from "./handoff.ts"; @@ -203,3 +204,99 @@ describe("pending context handoff", () => { ).toBe(poolId); }); }); + +describe("model switch change", () => { + const selection = (model: string, options?: Record) => ({ + instanceId: from, + model, + ...(options === undefined + ? {} + : { options: Object.entries(options).map(([id, value]) => ({ id, value })) }), + }); + const providers = [ + { + instanceId: from, + models: ["gpt", "mini"].map((slug) => ({ + slug, + capabilities: { + optionDescriptors: [ + { id: "serviceTier", type: "select", options: [{ id: "fast", label: "Fast" }] }, + { + id: "thought_level", + type: "select", + role: "reasoning", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + ], + }, + })), + }, + ] as unknown as ServerProvider[]; + + it("shows only what changed, reading an unset effort as the default", () => { + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { thought_level: "high" }) }, + providers, + ), + ).toEqual({ modelChanged: false, effort: { from: "Medium", to: "High" } }); + expect( + resolveModelSwitchChange({ from: selection("gpt"), to: selection("mini") }, providers), + ).toEqual({ modelChanged: true, effort: null }); + }); + + it("ignores changes the user cannot see", () => { + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { thought_level: "medium" }) }, + providers, + ), + ).toBeNull(); + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { serviceTier: "fast" }) }, + providers, + ), + ).toBeNull(); + // A cataloged model without an effort control ignores stale effort options. + expect( + resolveModelSwitchChange( + { + from: selection("plain", { reasoningEffort: "low" }), + to: selection("plain", { reasoningEffort: "high" }), + }, + [{ instanceId: from, models: [{ slug: "plain" }] }] as unknown as ServerProvider[], + ), + ).toBeNull(); + }); + + it("falls back to stated efforts without a catalog", () => { + expect( + resolveModelSwitchChange( + { + from: selection("gpt", { reasoningEffort: "low" }), + to: selection("gpt", { reasoningEffort: "high" }), + }, + [], + ), + ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } }); + expect( + resolveModelSwitchChange( + { + from: selection("pi", { thinking: "low" }), + to: selection("pi", { thinking: "high" }), + }, + [], + ), + ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } }); + expect( + resolveModelSwitchChange( + { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "high" }) }, + [], + ), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index 2b3f56a6ea09e..26f5732309dc6 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -1,12 +1,20 @@ import type { ModelSelection, + OrchestrationV2ModelSwitch, OrchestrationV2Run, OrchestrationV2TurnItem, ProviderInstanceId, ServerProvider, + ServerProviderModel, } from "@t3tools/contracts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, +} from "@t3tools/shared/model"; +import { isReasoningSelect } from "./modelTraits.ts"; import type { ThreadStructure } from "./state/models.ts"; +import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts"; export type HandoffTimelineRun = Pick< OrchestrationV2Run, @@ -84,3 +92,51 @@ function latestRunModelBefore( } return latest?.modelSelection.model; } + +export interface ModelSwitchChange { + readonly modelChanged: boolean; + /** Effort labels, set only when the effort changed. */ + readonly effort: { readonly from: string; readonly to: string } | null; +} + +/** + * What a recorded model switch visibly changed, or null when it changed neither the model nor the + * effort. The composer omits an untouched effort, so an unset effort reads as the model's default. + */ +export function resolveModelSwitchChange( + { from, to }: OrchestrationV2ModelSwitch, + providers: ReadonlyArray, +): ModelSwitchChange | null { + const modelsOf = (selection: ModelSelection) => + providers.find((provider) => provider.instanceId === selection.instanceId)?.models; + const fromEffort = resolveEffort(from, modelsOf(from)); + const toEffort = resolveEffort(to, modelsOf(to)); + const modelChanged = from.model !== to.model; + const effort = + fromEffort && toEffort && fromEffort.value !== toEffort.value + ? { from: fromEffort.label, to: toEffort.label } + : null; + return modelChanged || effort ? { modelChanged, effort } : null; +} + +/** The composer's reasoning control for the model; stated values only when the catalog lacks it. */ +function resolveEffort( + selection: ModelSelection, + models: ReadonlyArray | undefined, +): { readonly value: string; readonly label: string } | undefined { + const model = models?.find((candidate) => candidate.slug === selection.model); + if (model === undefined) { + // Pi states its effort as `thinking`; the catalog would otherwise mark it a reasoning control. + const value = [...REASONING_EFFORT_OPTION_IDS, "thinking"] + .map((id) => getModelSelectionStringOptionValue(selection, id)) + .find((candidate) => candidate !== undefined); + return value === undefined ? undefined : { value, label: value }; + } + const descriptor = model.capabilities?.optionDescriptors?.find(isReasoningSelect); + if (descriptor === undefined) return undefined; + const value = + getModelSelectionStringOptionValue(selection, descriptor.id) ?? + getProviderOptionCurrentValue(descriptor, selection); + if (typeof value !== "string") return undefined; + return { value, label: descriptor.options.find((option) => option.id === value)?.label ?? value }; +} diff --git a/packages/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index fb277617281b8..706c1aa157fc1 100644 --- a/packages/client-runtime/src/operations/commands.performance.test.ts +++ b/packages/client-runtime/src/operations/commands.performance.test.ts @@ -11,7 +11,7 @@ import { type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -137,12 +137,9 @@ const TARGET = new PrimaryConnectionTarget({ wsBaseUrl: "wss://environment.example.test", }); -const layerTestCrypto = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), +const layerTestRandom = Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ); const makeSupervisor = Effect.fn("CommandPerformance.makeSupervisor")(function* (input: { @@ -174,7 +171,7 @@ const makeSupervisor = Effect.fn("CommandPerformance.makeSupervisor")(function* }, }, } as never), - subscribeServerConfig: (requestInput) => client.subscribeServerConfig(requestInput), + subscribeServerConfig: (requestInput = {}) => client.subscribeServerConfig(requestInput), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -273,6 +270,6 @@ describe("routine command transport budget", () => { }); expect(currentRpcJsonBytes).toBeLessThanOrEqual(MAX_CURRENT_SEND_RPC_JSON_BYTES); expect(currentRpcJsonBytes / formerRpcJsonBytes).toBeLessThanOrEqual(0.000_06); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); }); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 32e1afc3396d3..15fc0e6176ec2 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -20,7 +20,7 @@ import { type ProjectMutation, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -43,12 +43,9 @@ import { startThreadTurn, } from "./commands.ts"; -const layerTestCrypto = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), +const layerTestRandom = Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ); const TARGET = new PrimaryConnectionTarget({ @@ -116,7 +113,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct }, }, } as never), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -154,7 +151,7 @@ describe("V2 environment commands", () => { { type: "queued-run.start", commandId: "send-selected", threadId, runId: "selected" }, { type: "queue.resume", commandId: "resume-queue", threadId }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("preserves caller command ids for idempotent V2 commands", () => @@ -170,7 +167,7 @@ describe("V2 environment commands", () => { expect(commands).toEqual([ { type: "thread.archive", commandId: "queued-command", threadId: "thread-1" }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("resolves run ordinal zero to the persisted thread-start checkpoint", () => @@ -214,7 +211,7 @@ describe("V2 environment commands", () => { checkpointId, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("preserves an existing worktree and branch during first-message launch", () => @@ -258,7 +255,7 @@ describe("V2 environment commands", () => { branch: "feature", }, }); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("uses server-resolved delivery intent without fetching the full projection", () => @@ -294,7 +291,7 @@ describe("V2 environment commands", () => { }); } expect(projectionRequests).toEqual([]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("Stop with no run left ends the thread's pull request watches", () => @@ -348,7 +345,7 @@ describe("V2 environment commands", () => { watching: false, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect.each(["waiting", "completed", "rolled_back"] as const)( @@ -419,7 +416,7 @@ describe("V2 environment commands", () => { }, ], ); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect( @@ -473,7 +470,7 @@ describe("V2 environment commands", () => { subagentId, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect( @@ -532,7 +529,7 @@ describe("V2 environment commands", () => { }, { type: "thread.stop", commandId: "lineage-stop", threadId: childThreadId }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("stops a thread without relying on background work in the history window", () => @@ -560,6 +557,6 @@ describe("V2 environment commands", () => { threadId: v2ThreadId, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index e3cbecfc2b2c4..777f7e2adb4f5 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -30,7 +30,7 @@ import { } from "@t3tools/contracts"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import { getInitialServerConfig, request } from "../rpc/client.ts"; @@ -270,8 +270,8 @@ const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(fun input: CommandMetadata, ) { if (input.commandId !== undefined) return input.commandId; - const crypto = yield* Crypto.Crypto; - return CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)); + const random = yield* SecureRandom.SecureRandom; + return CommandId.make(yield* random.uuidV4()); }); const dispatch = (command: OrchestrationV2Command) => diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts index 6af70685145da..dd03c314ecc4f 100644 --- a/packages/client-runtime/src/projectFaviconCache.test.ts +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -202,11 +202,19 @@ describe("project favicon image loader", () => { await expect(load(url, signal())).rejects.toThrow("too large"); expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); expect(downscale).not.toHaveBeenCalled(); + let cancelled = false; + const declaredStream = new ReadableStream({ + cancel() { + cancelled = true; + }, + }); const declared = loader( - new Response("x", { + new Response(declaredStream, { headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, }), ); await expect(declared.load(url, signal())).rejects.toThrow("too large"); + expect(cancelled).toBe(true); + expect(declared.downscale).not.toHaveBeenCalled(); }); }); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts index 0e53de3136b9e..d779a27acebef 100644 --- a/packages/client-runtime/src/projectFaviconCache.ts +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -50,7 +50,10 @@ export interface ProjectFaviconStorage { async function readBounded(response: Response, maxBytes: number) { const declared = Number(response.headers.get("content-length")); - if (declared > maxBytes) throw new Error("Project icon is too large to decode."); + if (declared > maxBytes) { + await response.body?.cancel().catch(() => {}); + throw new Error("Project icon is too large to decode."); + } if (!response.body) { const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); diff --git a/packages/client-runtime/src/providerClients.ts b/packages/client-runtime/src/providerClients.ts index 37cd15209614a..e23200653b963 100644 --- a/packages/client-runtime/src/providerClients.ts +++ b/packages/client-runtime/src/providerClients.ts @@ -11,6 +11,7 @@ import { modelShortName } from "@t3tools/provider-core/modelShortName"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { fxClient } from "@t3tools/provider-fx/client"; import { grokClient } from "@t3tools/provider-grok/client"; +import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -41,6 +42,7 @@ export const providerClients = makeProviderClientRegistry([ acpRegistryAgentIds: ["antigravity-acp"], settingsSchema: AntigravitySettings, }, + kiroClient, museClient, piClient, acpRegistryClient, diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index fec55deb77f82..eb148ec53cb84 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -48,7 +48,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 1bc3292dbc03f..8ce5adee5f339 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -447,7 +447,7 @@ describe("RpcSessionFactory", () => { yield* completeInitialConfig(socket, encodeServerConfig(SOURCE_SERVER_CONFIG), options); yield* Fiber.join(ready); const observed = yield* Queue.unbounded(); - yield* session.subscribeServerConfig(options).pipe( + yield* session.subscribeServerConfig().pipe( Stream.runForEach((event) => Queue.offer(observed, event)), Effect.forkChild, ); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 250c7889c6fce..fa6545618446c 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -49,7 +49,7 @@ export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; readonly subscribeServerConfig: ( - input: ServerConfigSubscriptionInput, + input?: ServerConfigSubscriptionInput, ) => ServerConfigSubscription; readonly ready: Effect.Effect; readonly probe: Effect.Effect; @@ -394,7 +394,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( }), ), ); - const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => + const subscribeServerConfig = (input: ServerConfigSubscriptionInput = serverConfigInput) => Stream.unwrap( validatedInitialConfig.pipe( Effect.as( diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts index f69a814df7d08..acb7b8c5728e4 100644 --- a/packages/client-runtime/src/state/archivedThreads.test.ts +++ b/packages/client-runtime/src/state/archivedThreads.test.ts @@ -1,12 +1,18 @@ -import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts"; +import { + EnvironmentId, + type OrchestrationV2ArchivedShellSnapshot, + type ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; -import { expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { createArchivedThreadSnapshotsAtomFamily, makeArchivedThreadsEnvironmentKey, } from "./archivedThreads.ts"; +import { v2Project, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; it("does not expose an archived snapshot failure message", () => { const environmentId = EnvironmentId.make("env-sensitive"); @@ -17,6 +23,10 @@ it("does not expose an archived snapshot failure message", () => { Cause.fail(new Error("credential=secret-value")), ), ), + hiddenThreadTagsAtom: () => Atom.make(null), + hiddenThreadKeysAtom: Atom.make((): ReadonlySet => new Set()), + liveThreadRefsAtom: Atom.make((): ReadonlyArray => []), + anyEnvironmentHidingAtom: Atom.make(false), labelPrefix: "test:archived-thread-snapshots", }); const registry = AtomRegistry.make(); @@ -29,3 +39,70 @@ it("does not expose an archived snapshot failure message", () => { registry.dispose(); }); + +describe("archived delegated tasks follow their parent on another machine", () => { + const parentEnvironmentId = EnvironmentId.make("env-parent"); + const taskEnvironmentId = EnvironmentId.make("env-task"); + const parentId = ThreadId.make("thread-parent"); + const page = ( + threads: OrchestrationV2ArchivedShellSnapshot["threads"], + ): OrchestrationV2ArchivedShellSnapshot => ({ + schemaVersion: 1, + snapshotSequence: 1, + projects: [v2Project], + threads, + }); + const taskPage = page([ + { + ...v2ThreadShell, + id: ThreadId.make("thread-task"), + delegatedFrom: { environmentId: parentEnvironmentId, threadId: parentId, title: "p" }, + }, + ]); + const archivedTaggedParent = page([{ ...v2ThreadShell, id: parentId, tags: ["private"] }]); + + it.each([ + { name: "archived tagged parent", parentPage: archivedTaggedParent, live: false, tasks: 0 }, + { name: "live visible parent", parentPage: page([]), live: true, tasks: 1 }, + { name: "parent page not loaded yet", parentPage: null, live: false, tasks: 0 }, + { name: "archive scoped to the task's machine", parentPage: undefined, live: false, tasks: 0 }, + { + name: "only a third machine hides tags", + parentPage: page([]), + live: false, + tasks: 0, + parentTags: null, + }, + ])("$name", ({ parentPage, live, tasks, parentTags = ["private"] }) => { + const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: (environmentId) => + Atom.make( + environmentId === taskEnvironmentId + ? AsyncResult.success(taskPage) + : parentPage === null || parentPage === undefined + ? AsyncResult.initial(true) + : AsyncResult.success(parentPage), + ), + hiddenThreadTagsAtom: (environmentId) => + Atom.make(environmentId === parentEnvironmentId ? parentTags : null), + hiddenThreadKeysAtom: Atom.make((): ReadonlySet => new Set()), + liveThreadRefsAtom: Atom.make((): ReadonlyArray => + live ? [{ environmentId: parentEnvironmentId, threadId: parentId }] : [], + ), + // The parent's machine hides tags; a scoped archive learns that only from this flag. + anyEnvironmentHidingAtom: Atom.make(true), + labelPrefix: "test:archived-thread-snapshots", + }); + const registry = AtomRegistry.make(); + const { snapshots } = registry.get( + snapshotsAtom( + makeArchivedThreadsEnvironmentKey( + parentPage === undefined ? [taskEnvironmentId] : [parentEnvironmentId, taskEnvironmentId], + ), + ), + ); + const taskSnapshot = snapshots.find((entry) => entry.environmentId === taskEnvironmentId); + expect(taskSnapshot?.snapshot.threads.length).toBe(tasks); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts index 3a67dc1c52fcf..067a913d018b4 100644 --- a/packages/client-runtime/src/state/archivedThreads.ts +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -1,9 +1,16 @@ -import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts"; +import { + EnvironmentId, + type OrchestrationV2ArchivedShellSnapshot, + type ScopedThreadRef, +} from "@t3tools/contracts"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; import { AsyncResult, Atom } from "effect/reactivity"; +import { threadKey } from "./entities.ts"; +import { hiddenThreadKeys, type TagFilteredEnvironment } from "./threadTagFilter.ts"; + export interface ArchivedSnapshotEntry { readonly environmentId: EnvironmentId; readonly snapshot: OrchestrationV2ArchivedShellSnapshot; @@ -40,11 +47,21 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { readonly getSnapshotAtom: ( environmentId: EnvironmentId, ) => Atom.Atom>; + readonly hiddenThreadTagsAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom | null>; + /** Live threads the tag filter hides, so archived children of hidden parents stay hidden. */ + readonly hiddenThreadKeysAtom: Atom.Atom>; + /** Live threads, so archived children of loaded, visible parents stay visible. */ + readonly liveThreadRefsAtom: Atom.Atom>; + /** Whether any connected environment hides tags, including ones outside this archive's scope. */ + readonly anyEnvironmentHidingAtom: Atom.Atom; readonly labelPrefix: string; }) { return Atom.family((environmentKey: string) => Atom.make((get): ArchivedThreadSnapshotsState => { const snapshots: ArchivedSnapshotEntry[] = []; + const environments: TagFilteredEnvironment[] = []; let error: string | null = null; let isLoading = false; @@ -56,13 +73,41 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { if (snapshot !== null) { snapshots.push({ environmentId, snapshot }); } + // A page that has not arrived still counts, so its tags apply to children elsewhere. + environments.push({ + environmentId, + threads: snapshot?.threads ?? [], + hiddenTags: get(options.hiddenThreadTagsAtom(environmentId)), + }); if (error === null && result._tag === "Failure") { error = "Failed to load archived threads."; } } - return { snapshots, error, isLoading }; + // One pass over every environment's page, so archived tasks follow parents archived elsewhere. + // Any connected machine may hide an unloaded parent, so the global flag decides, not the scope. + const hidingAnywhere = get(options.anyEnvironmentHidingAtom); + const hidden = hiddenThreadKeys( + environments, + get(options.hiddenThreadKeysAtom), + hidingAnywhere ? new Set(get(options.liveThreadRefsAtom).map(threadKey)) : undefined, + hidingAnywhere, + ); + if (hidden.size === 0) return { snapshots, error, isLoading }; + return { + snapshots: snapshots.map(({ environmentId, snapshot }) => ({ + environmentId, + snapshot: { + ...snapshot, + threads: snapshot.threads.filter( + (thread) => !hidden.has(threadKey({ environmentId, threadId: thread.id })), + ), + }, + })), + error, + isLoading, + }; }).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)), ); } diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index aaee2be002219..1c0b2780bc1a3 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; -import { type AssetCreateUrlResult, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { + type AssetCreateUrlResult, + type AssetResource, + EnvironmentId, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -22,6 +28,7 @@ import { ASSET_URL_MAX_IN_FLIGHT, createAssetEnvironmentAtoms, createProjectFaviconUrlAtomFamily, + makeRevisionedAssetUrlFamily, } from "./assets.ts"; describe("project favicon URL cache", () => { @@ -230,3 +237,151 @@ describe("asset URL requests", () => { ), ); }); + +const revisionedEnvironmentId = EnvironmentId.make("environment-1"); +const revisionedResource: AssetResource = { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: "docs/diagram.png", +}; + +type MintResult = AsyncResult.AsyncResult<{ + relativeUrl: string; + expiresAt: number; + fileRevision?: string; +}>; + +const minted = (n: number, fileRevision?: string): MintResult => + AsyncResult.success({ + relativeUrl: `/a/${n}`, + expiresAt: 60 * 60_000, + ...(fileRevision === undefined ? {} : { fileRevision }), + }); + +// A stand-in for the asset URL query: a refresh starts a mint (counted, shown as +// waiting) and `settle` completes the latest one. +function setup(initial: MintResult) { + let mints = 0; + // Node removal runs on the registry's scheduler; `unmount` flushes it so the + // next mount of the same revision builds a fresh node. + const scheduled: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + scheduled.push(task); + return () => {}; + }, + }); + const server = Atom.make(initial).pipe(Atom.keepAlive); + const query = Atom.readable( + (get) => get(server), + () => { + mints += 1; + registry.set(server, AsyncResult.waiting(registry.get(server))); + }, + ).pipe(Atom.keepAlive); + const revisioned = makeRevisionedAssetUrlFamily( + () => query, + () => 0, + ); + const atom = (revision: string | null) => + revisioned({ + environmentId: revisionedEnvironmentId, + input: { resource: revisionedResource }, + revision, + }); + const mount = (revision: string | null) => { + const release = registry.mount(atom(revision)); + return () => { + release(); + for (const task of scheduled.splice(0)) task(); + }; + }; + const settle = (fileRevision?: string) => registry.set(server, minted(mints + 1, fileRevision)); + const url = (revision: string | null) => { + const result = registry.get(atom(revision)); + return result._tag === "Success" ? result.value.relativeUrl : null; + }; + return { mount, settle, url, mints: () => mints }; +} + +describe("makeRevisionedAssetUrlFamily", () => { + it("re-mints a settled URL on first read and on each new revision, not on remount", () => { + const { mount, settle, mints } = setup(minted(0)); + + mount("mutation-1")(); + expect(mints()).toBe(1); + settle(); + + mount("mutation-1")(); + expect(mints()).toBe(1); + + mount("mutation-2")(); + expect(mints()).toBe(2); + }); + + it("re-mints when a revert returns to an earlier revision", () => { + const { mount, settle, mints } = setup(minted(0)); + + mount("mutation-a")(); + settle(); + mount("mutation-b")(); + settle(); + expect(mints()).toBe(2); + + const reverted = mount("mutation-a"); + expect(mints()).toBe(3); + reverted(); + }); + + it("shares a mint in flight on the first read", () => { + const { mount, mints } = setup(AsyncResult.initial(true)); + + mount("mutation-1")(); + expect(mints()).toBe(0); + }); + + it("re-mints after a mint that was in flight when the revision changed", () => { + const { mount, settle, mints } = setup(minted(0)); + + const before = mount("mutation-1"); + settle(); + const edited = mount("mutation-2"); + expect(mints()).toBe(2); + before(); + + const stillMinting = mount("mutation-3"); + edited(); + expect(mints()).toBe(2); + + settle(); + expect(mints()).toBe(3); + settle(); + expect(mints()).toBe(3); + stillMinting(); + }); + + it("keeps the URL while the file is unchanged and swaps it once the file changes", () => { + const { mount, settle, url } = setup(minted(0, "file-a")); + + const first = mount("mutation-1"); + settle("file-a"); + first(); + + const unchanged = mount("mutation-2"); + settle("file-a"); + expect(url("mutation-2")).toBe("/a/0"); + unchanged(); + + // The query now holds /a/3; a later revision must still keep the URL on screen. + const stillUnchanged = mount("mutation-3"); + expect(url("mutation-3")).toBe("/a/0"); + settle("file-a"); + expect(url("mutation-3")).toBe("/a/0"); + stillUnchanged(); + + const edited = mount("mutation-4"); + settle("file-b"); + expect(url("mutation-4")).toBe("/a/5"); + edited(); + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index f2e2c0e90bb48..56b1b5bcd7d70 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -131,6 +131,113 @@ export function assetUrlStateFromResult( }; } +type AssetUrlTarget = { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; +}; + +const decodeRevisionedAssetUrlKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, AssetResource, Schema.NullOr(Schema.String)]), +); + +const isSettled = (result: AsyncResult.AsyncResult) => + !AsyncResult.isInitial(result) && !result.waiting; + +const KEEP_URL_MIN_LIFE_MS = 5 * 60_000; + +/** + * A re-mint signs a new URL even when the file is unchanged, and a new URL reloads the image. + * Keep the shown URL while the server reports the same file revision and it has time left. + */ +function keepUnchangedFile( + shown: AsyncResult.AsyncResult, + next: AsyncResult.AsyncResult, + now: number, +) { + if ( + shown._tag !== "Success" || + next._tag !== "Success" || + next.value.fileRevision === undefined || + next.value.fileRevision !== shown.value.fileRevision || + shown.value.expiresAt - now <= KEEP_URL_MIN_LIFE_MS + ) { + return next; + } + const kept = AsyncResult.success(shown.value); + return next.waiting ? AsyncResult.waiting(kept) : kept; +} + +/** + * The URL is minted per resource and shared across revisions, so reading a + * different revision (even an earlier one after a revert) re-mints it. An + * in-flight mint is shared only on first read; after an edit the server must + * re-read the file, so the re-mint waits for that mint to settle. + */ +export function makeRevisionedAssetUrlFamily( + createUrl: ( + target: AssetUrlTarget, + ) => Atom.Atom>, + now: () => number = Date.now, +) { + // Outside the nodes: a node is dropped once unmounted, and the next read of + // its revision must still see the revisions minted since. Never pruned on + // purpose: pruning would forget the revision and re-mint on the next mount. + // It holds one short entry per file shown, and goes with the registry. + const mintedUnder = new WeakMap>(); + // Also per resource: a new revision builds a new node, which must keep the URL on screen. + const shownUnder = new WeakMap< + AtomRegistry.AtomRegistry, + Map> + >(); + const family = Atom.family((key: string) => { + const [environmentId, resource, revision] = decodeRevisionedAssetUrlKey(JSON.parse(key)); + const resourceKey = JSON.stringify([environmentId, resource]); + const query = createUrl({ environmentId, input: { resource } }); + return Atom.transform( + query, + (get) => { + let minted = mintedUnder.get(get.registry); + if (minted === undefined) { + minted = new Map(); + mintedUnder.set(get.registry, minted); + } + const firstRead = !minted.has(resourceKey); + let pending = minted.get(resourceKey) !== revision; + const current = get.once(query); + if (pending && (firstRead || isSettled(current))) { + pending = false; + minted.set(resourceKey, revision); + if (isSettled(current)) get.refresh(query); + } + let shownByResource = shownUnder.get(get.registry); + if (shownByResource === undefined) { + shownByResource = new Map(); + shownUnder.set(get.registry, shownByResource); + } + const show = (value: AsyncResult.AsyncResult) => { + const previous = shownByResource.get(resourceKey); + const shown = previous === undefined ? value : keepUnchangedFile(previous, value, now()); + shownByResource.set(resourceKey, shown); + return shown; + }; + get.subscribe(query, (value) => { + get.setSelf(show(value)); + if (!pending || !isSettled(value)) return; + pending = false; + minted.set(resourceKey, revision); + get.refresh(query); + }); + return show(get.once(query)); + }, + { initialValueTarget: query }, + // No idle TTL: each mount rebuilds the node, so a revision read again after + // another one is compared against the record instead of a cached node. + ).pipe(Atom.setIdleTTL(0)); + }); + return (target: AssetUrlTarget & { readonly revision: string | null }) => + family(JSON.stringify([target.environmentId, target.input.resource, target.revision])); +} + export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, localMediaEnvironment?: Atom.Atom<{ diff --git a/packages/client-runtime/src/state/attachments.test.ts b/packages/client-runtime/src/state/attachments.test.ts index c1376bc786fd5..a5396dcc0c66c 100644 --- a/packages/client-runtime/src/state/attachments.test.ts +++ b/packages/client-runtime/src/state/attachments.test.ts @@ -9,7 +9,11 @@ import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import type { AtomCommand } from "./runtime.ts"; -import { runAttachmentUploadCycle, verifyPersistedAttachmentUpload } from "./attachments.ts"; +import { + formatAttachmentSize, + runAttachmentUploadCycle, + verifyPersistedAttachmentUpload, +} from "./attachments.ts"; const environmentId = EnvironmentId.make("environment-1"); const registry = {} as AtomRegistry.AtomRegistry; @@ -129,3 +133,14 @@ describe("verifyPersistedAttachmentUpload", () => { expect(lookups).toBe(2); }); }); + +describe("formatAttachmentSize", () => { + it.each([ + [1_047_552, "1023 KB"], + [1_047_553, "1.0 MB"], + [1_048_575, "1.0 MB"], + [1_048_576, "1.0 MB"], + ])("formats %d bytes as %s", (sizeBytes, label) => { + expect(formatAttachmentSize(sizeBytes)).toBe(label); + }); +}); diff --git a/packages/client-runtime/src/state/attachments.ts b/packages/client-runtime/src/state/attachments.ts index a78113a201097..800393470ffad 100644 --- a/packages/client-runtime/src/state/attachments.ts +++ b/packages/client-runtime/src/state/attachments.ts @@ -194,9 +194,9 @@ export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number) } export function formatAttachmentSize(sizeBytes: number): string { - return sizeBytes >= 1024 * 1024 - ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` - : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`; + const kilobytes = Math.max(1, Math.ceil(sizeBytes / 1024)); + // Pick the unit after rounding, so 1023.5 KB reads "1.0 MB", not "1024 KB". + return kilobytes >= 1024 ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` : `${kilobytes} KB`; } export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string { diff --git a/packages/client-runtime/src/state/git.ts b/packages/client-runtime/src/state/git.ts index ddce3ca4da8c8..18940290a997a 100644 --- a/packages/client-runtime/src/state/git.ts +++ b/packages/client-runtime/src/state/git.ts @@ -3,10 +3,12 @@ import { Atom } from "effect/reactivity"; import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import * as Persistence from "../platform/persistence.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts"; export function createGitEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { return { pullRequestResolution: createEnvironmentRpcQueryAtomFamily(runtime, { @@ -18,6 +20,8 @@ export function createGitEnvironmentAtoms( tag: WS_METHODS.gitPreparePullRequestThread, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: ({ environmentId, input }, registry) => + invalidateCachedVcsRefs(registry, { environmentId, cwd: input.cwd }), }), }; } diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 5f5c2ce3020ac..94dc18582a56e 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -103,6 +103,13 @@ export function createOrchestrationEnvironmentAtoms( staleTimeMs: 300_000, idleTtlMs: 300_000, }), + // Mounted only while a subagent inspector is open; running children change between opens. + nativeChildWork: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:native-child-work", + tag: ORCHESTRATION_V2_WS_METHODS.getNativeChildWork, + staleTimeMs: 0, + idleTtlMs: 0, + }), stopWorkflow: createEnvironmentRpcCommand(runtime, { label: "environment-data:orchestration:stop-workflow", tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index fbba7d1bd9dd4..d9368ef4058a4 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -293,8 +293,9 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.provider-switched": case "thread.guest-share-updated": return { ...base, thread: event.payload }; - // Visited tracking is read state, not activity: skip the updatedAt bump. + // Visited tracking and tags are not activity: skip the updatedAt bump. case "thread.visited": + case "thread.tags-updated": case "thread.marked-unread": return { ...projection, thread: event.payload }; case "run.created": diff --git a/packages/client-runtime/src/state/projectCommands.test.ts b/packages/client-runtime/src/state/projectCommands.test.ts index 727dd87ff6864..000466b05e1da 100644 --- a/packages/client-runtime/src/state/projectCommands.test.ts +++ b/packages/client-runtime/src/state/projectCommands.test.ts @@ -11,7 +11,7 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -107,11 +107,8 @@ const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), } as EnvironmentRegistry.EnvironmentRegistry["Service"]), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ), ), ); diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 03db9b869cc06..caa706d86d46e 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -5,7 +5,7 @@ import { type ScopedProjectRef, WS_METHODS, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; @@ -73,7 +73,7 @@ export class ScratchProjectNotLoadedError extends Schema.TaggedError( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, options: { readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom; }, diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index cbd2542e0eba6..453ee7440933f 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -510,7 +510,7 @@ function session(client: WsRpcProtocolClient): RpcSession { client[WS_METHODS.pullRequestsInvalidate] ?? (() => Effect.void), }, initialConfig: client[WS_METHODS.serverGetConfig]?.({}).pipe(Effect.orDie) ?? Effect.never, - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index f0db154b9257b..628bb29382d9e 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -97,7 +97,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.succeed(CONFIG), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 198f74962eb98..93783f2f528b6 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1082,6 +1082,26 @@ export function createServerEnvironmentAtoms( Atom.withLabel(`environment-data:server:settings:${environmentId}`), ), ); + const hiddenThreadTagsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const settings = get(settingsValueAtom(environmentId)); + if (settings?.hideTaggedThreads !== true) return null; + // Tags are trimmed on write; trim hand-edited entries so " private" still matches. + const tags = settings.hiddenThreadTags.map((tag) => tag.trim()).filter((tag) => tag !== ""); + return tags.length > 0 ? tags : null; + }).pipe( + // Trimming makes a new array on every settings push; keep equal lists stable. + Atom.withEquality( + (a: ReadonlyArray | null, b: ReadonlyArray | null) => + a === b || + (a !== null && + b !== null && + a.length === b.length && + a.every((tag, index) => tag === b[index])), + ), + Atom.withLabel(`environment-data:server:hidden-thread-tags:${environmentId}`), + ), + ); const usagePricesAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { const overrides = get(settingsValueAtom(environmentId))?.usagePriceOverrides ?? {}; @@ -1116,6 +1136,13 @@ export function createServerEnvironmentAtoms( ]); }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), ); + // Settings snapshots rebuild the filter object; only a changed filter should touch the Usage tab. + const usageModelFilterAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(settingsValueAtom(environmentId))?.usageModelFilter).pipe( + Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b)), + Atom.withLabel(`environment-data:server:usage-model-filter:${environmentId}`), + ), + ); const providersValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( Atom.withLabel(`environment-data:server:providers:${environmentId}`), @@ -1248,6 +1275,8 @@ export function createServerEnvironmentAtoms( }), updateStateAtom, settingsValueAtom, + hiddenThreadTagsAtom, + usageModelFilterAtom, providersValueAtom, providerAuthState: (target: { readonly environmentId: EnvironmentId; diff --git a/packages/client-runtime/src/state/serverAuth.test.ts b/packages/client-runtime/src/state/serverAuth.test.ts index 4d1ddd8df7453..283df5785fc89 100644 --- a/packages/client-runtime/src/state/serverAuth.test.ts +++ b/packages/client-runtime/src/state/serverAuth.test.ts @@ -99,7 +99,7 @@ it.effect("waits for a newly configured provider and preserves its active auth s const session: RpcSession = { client, initialConfig: Effect.succeed(config), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/serverUsage.test.ts b/packages/client-runtime/src/state/serverUsage.test.ts index 7adbec72c3717..9552a8873f40f 100644 --- a/packages/client-runtime/src/state/serverUsage.test.ts +++ b/packages/client-runtime/src/state/serverUsage.test.ts @@ -132,7 +132,7 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( const session: RpcSession = { client, initialConfig: Effect.succeed(CONFIG), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 53b0e62169384..200561015da7b 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -57,7 +57,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 2b1b1162d6bc9..bc210e18ac04f 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -13,7 +13,7 @@ import { type OrchestrationV2ShellSnapshot, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -117,11 +117,8 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), } as EnvironmentRegistry.EnvironmentRegistry["Service"]), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size)), ), Layer.succeed( ThreadHistoryController.ThreadHistoryController, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index e0aae375ec5df..88a5759c5ed50 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -1,5 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; @@ -69,7 +69,7 @@ export type LoadEarlierThreadHistoryInput = { }; export function createThreadEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, snapshotAtom: (environmentId: EnvironmentId) => Atom.Atom, ) { const scheduler = createAtomCommandScheduler(); diff --git a/packages/client-runtime/src/state/threadDetail.test.ts b/packages/client-runtime/src/state/threadDetail.test.ts index 3e66202d0bb4b..cde705bdfb157 100644 --- a/packages/client-runtime/src/state/threadDetail.test.ts +++ b/packages/client-runtime/src/state/threadDetail.test.ts @@ -14,7 +14,12 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { v2Now, v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts"; import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; -import { createEnvironmentThreadDetailAtoms, sameThreadStructure } from "./threadDetail.ts"; +import type { ThreadStructureVisibleTurnItem } from "./models.ts"; +import { + createEnvironmentThreadDetailAtoms, + deriveWorkspaceMutationId, + sameThreadStructure, +} from "./threadDetail.ts"; import { mergeOlderHistoryIntoProjection } from "./threadHistoryMerge.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE } from "./threadState.ts"; @@ -349,3 +354,35 @@ it("matches full structure comparisons across streamed, batched, and reordered u [assistant.id, 1], ]); }); + +describe("deriveWorkspaceMutationId", () => { + const item = (id: string, type: string, status: string) => + ({ + item: { id: TurnItemId.make(id), type, status }, + }) as unknown as ThreadStructureVisibleTurnItem; + + it("keys on the latest finished command or file change and the latest checkpoint", () => { + expect( + deriveWorkspaceMutationId( + [ + item("edit", "file_change", "completed"), + item("reply", "assistant_message", "completed"), + item("command", "command_execution", "running"), + ], + "2026-01-01T00:00:00.000Z", + ), + ).toBe(JSON.stringify(["edit", "2026-01-01T00:00:00.000Z"])); + expect( + deriveWorkspaceMutationId( + [item("edit", "file_change", "completed"), item("mcp", "dynamic_tool", "completed")], + null, + ), + ).toBe(JSON.stringify(["mcp", null])); + }); + + it("is null before anything could have touched the workspace", () => { + expect(deriveWorkspaceMutationId([item("reply", "assistant_message", "completed")], null)).toBe( + null, + ); + }); +}); diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index eeeda6a2f348f..7a17e7326b802 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -43,6 +43,38 @@ const EMPTY_THREAD_STRUCTURE_TURN_ITEMS: ReadonlyArray = Object.freeze([]); +/** + * Changes when the agent may have changed the workspace, so file views and host images refresh. + * File tools are explicit; commands, MCP or dynamic tools and subagents are included because + * they can mutate the workspace without reporting the paths they touched. + */ +export function deriveWorkspaceMutationId( + visibleTurnItems: ReadonlyArray, + latestCheckpointCompletedAt: string | null, +) { + let itemId: string | null = null; + for (let index = visibleTurnItems.length - 1; index >= 0; index -= 1) { + const item = visibleTurnItems[index]?.item; + if (!item) continue; + if ( + item.type !== "command_execution" && + item.type !== "file_change" && + item.type !== "dynamic_tool" && + item.type !== "subagent" + ) { + continue; + } + if (item.status === "pending" || item.status === "running" || item.status === "waiting") { + continue; + } + itemId = item.id; + break; + } + return itemId === null && latestCheckpointCompletedAt === null + ? null + : JSON.stringify([itemId, latestCheckpointCompletedAt]); +} + type TurnItem = OrchestrationV2ThreadProjection["turnItems"][number]; type VisibleTurnItem = OrchestrationV2ThreadProjection["visibleTurnItems"][number]; type AppThread = OrchestrationV2ThreadProjection["thread"]; @@ -501,8 +533,19 @@ export function createEnvironmentThreadDetailAtoms( ), ); + const workspaceMutationIdAtomFamily = Atom.family((key: string) => + Atom.make((get) => + deriveWorkspaceMutationId( + get(quietVisibleTurnItemsAtomFamily(key)), + get(checkpointSummariesAtomFamily(key)).at(-1)?.completedAt ?? null, + ), + ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-workspace-mutation-id:${key}`)), + ); + return { worktreePathAtom: (ref: ScopedThreadRef) => worktreePathAtomFamily(threadKey(ref)), + workspaceMutationIdAtom: (ref: ScopedThreadRef) => + workspaceMutationIdAtomFamily(threadKey(ref)), updatedAtAtom: (ref: ScopedThreadRef) => updatedAtAtomFamily(threadKey(ref)), lastVisitedAtAtom: (ref: ScopedThreadRef) => lastVisitedAtAtomFamily(threadKey(ref)), pendingRequestsAtom: (ref: ScopedThreadRef) => pendingRequestsAtomFamily(threadKey(ref)), diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 361f44f717edb..b6151c7c875be 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -433,12 +433,10 @@ export function presentPendingBackgroundWork( if (items.length === 1 && only !== undefined) { const noun = BACKGROUND_WORK_KINDS[only.kind].singular; const named = only.label !== noun; - const title = waiting - ? named - ? `${only.kind === "monitor" ? "" : `Waiting on ${noun} `}${only.label}` - : `Waiting on a ${noun}` - : named - ? `Running: ${only.label}` + const title = named + ? `${waiting ? "Waiting" : "Running"} · ${only.label}` + : waiting + ? `Waiting on a ${noun}` : `Running a ${noun}`; return { title, items, waiting }; } diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 6fd678b66b129..cec1774e6435d 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -112,10 +112,7 @@ export function mergeOlderHistoryIntoProjection( turnItemById.set(String(row.sourceItemId), row.item); } if (page.length === 0 && work === undefined) return projection; - let visible = [ - ...page, - ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), - ]; + let visible: OrchestrationV2ProjectedTurnItem[]; if (work && anchor && deferred) { let end = start + 1; while (end < projection.visibleTurnItems.length) { @@ -147,6 +144,11 @@ export function mergeOlderHistoryIntoProjection( ...projection.visibleTurnItems.slice(end), ]; if (anchor.visibility === "local") turnItemById.set(String(updated.item.id), updated.item); + } else { + visible = [ + ...page, + ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), + ]; } if ( visible.length === projection.visibleTurnItems.length && diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 43294c02a9b40..2669416db42fe 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -112,7 +112,7 @@ type ThreadKeyInput = Pick; * run can end moments before its continuation starts, so clients wait this long * before treating the thread as done. */ -export const WORKING_EXIT_GRACE_MS = 2_000; +const WORKING_EXIT_GRACE_MS = 2_000; /** * Keep one at module scope so placement survives routes that unmount the list. Call @@ -123,7 +123,7 @@ export function createInboxReturnTracker() { const keyOf = (thread: ThreadKeyInput) => `${thread.environmentId}:${thread.id}`; const states = new Map< string, - { working: boolean; deadline: number | null; placement: string } + { working: boolean; deadline: number | null; placement: string | null } >(); const returns = new Map(); const listeners = new Set<() => void>(); @@ -184,17 +184,21 @@ export function createInboxReturnTracker() { for (const thread of threads) { const key = keyOf(thread); present.add(key); - const placement = JSON.stringify([ - thread.archivedAt, - thread.deletedAt, - thread.pinnedAt, - thread.settledOverride, - thread.settledAt, - thread.snoozedAt, - thread.snoozedUntil, - thread.activeOrderKey, - ]); const previous = states.get(key); + const actual = isThreadWorking(thread); + const placement = + actual || previous?.working + ? JSON.stringify([ + thread.archivedAt, + thread.deletedAt, + thread.pinnedAt, + thread.settledOverride, + thread.settledAt, + thread.snoozedAt, + thread.snoozedUntil, + thread.activeOrderKey, + ]) + : null; const actionable = thread.hasPendingApprovals || thread.hasPendingUserInput || @@ -210,7 +214,6 @@ export function createInboxReturnTracker() { thread.pinnedAt != null || thread.settledOverride === "settled" || effectiveSnoozed(thread, { now: observedAt }); - const actual = isThreadWorking(thread); // Only shelf placement is held: execution state and attention requests stay authoritative. const deadline = !actual && previous?.working && !actionable && !moved && !excluded diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index d9aaf9fdca71a..c2e1024e232ae 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -2,6 +2,8 @@ import { presentThreadShell } from "./models.ts"; import { threadKey } from "./entities.ts"; import { EnvironmentId, + EventId, + MessageId, ThreadId, type ScopedThreadRef, NodeId, @@ -13,6 +15,7 @@ import { import { describe, expect, it, vi } from "vite-plus/test"; import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; import * as Cause from "effect/Cause"; +import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; import { v2Now, v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { createQuestionHistoryProjector, @@ -360,6 +363,210 @@ it("restores old text answers without mutating history or replacing unchanged ro expect(project({ ...answered, visibleTurnItems: [...rows] })[0]).toBe(result[0]); }); +type QuestionItem = Extract< + OrchestrationV2ThreadProjection["turnItems"][number], + { readonly type: "user_input_request" } +>; + +describe("question history through exact row replacements", () => { + const baseItem = { + threadId: v2Projection.thread.id, + runId: null, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + status: "running" as const, + title: null, + startedAt: v2Now, + completedAt: null, + updatedAt: v2Now, + }; + const assistantItem = (ordinal: number, text = "Hel") => ({ + ...baseItem, + id: TurnItemId.make(`assistant-${ordinal}`), + ordinal, + type: "assistant_message" as const, + messageId: MessageId.make(`message-${ordinal}`), + text, + streaming: true, + }); + const localRow = ( + item: OrchestrationV2ThreadProjection["turnItems"][number], + position: number, + ) => ({ + item, + position, + sourceItemId: item.id, + sourceThreadId: item.threadId, + visibility: "local" as const, + }); + const updated = ( + current: OrchestrationV2ThreadProjection, + item: OrchestrationV2ThreadProjection["turnItems"][number], + ) => + applyOrchestrationV2ProjectionEvent(current, { + id: EventId.make(`event-${item.id}`), + type: "turn-item.updated", + threadId: current.thread.id, + occurredAt: v2Now, + payload: item, + })!; + + it("reads only the replaced row and leaves the request index alone", () => { + const size = 1_000; + const counts = { items: 0, requestIds: 0 }; + const items = Array.from({ length: size }, (_, index) => assistantItem(index)); + const countedRows = items.map((item, index) => { + const row = localRow(item, index); + return Object.defineProperty(row, "item", { + enumerable: true, + get: () => { + counts.items += 1; + return item; + }, + }); + }); + const countedRequests = Array.from({ length: 400 }, (_, index) => { + const request = { + ...projection.runtimeRequests[0]!, + id: RuntimeRequestId.make(`request-${index}`), + }; + return Object.defineProperty(request, "id", { + enumerable: true, + get: () => { + counts.requestIds += 1; + return RuntimeRequestId.make(`request-${index}`); + }, + }); + }); + const initial: OrchestrationV2ThreadProjection = { + ...v2Projection, + turnItems: items, + visibleTurnItems: countedRows, + runtimeRequests: countedRequests, + }; + const project = createQuestionHistoryProjector(); + const scan = (current: OrchestrationV2ThreadProjection) => { + counts.items = 0; + counts.requestIds = 0; + const result = project(current); + return { result, ...counts }; + }; + expect(scan(initial)).toMatchObject({ items: size, requestIds: 400 }); + + const adjacent = updated(initial, assistantItem(size - 1, "Hello")); + const hinted = scan(adjacent); + expect(hinted.result).toBe(adjacent.visibleTurnItems); + expect(hinted).toMatchObject({ items: 0, requestIds: 0 }); + + // A skipped delta has no hint for this exact pair, so it scans again. + const skipped = updated( + updated(adjacent, assistantItem(size - 1, "Hello t")), + assistantItem(size - 1, "Hello there"), + ); + const fallback = scan(skipped); + expect(fallback.result).toBe(skipped.visibleTurnItems); + expect(fallback).toMatchObject({ items: size - 1, requestIds: 0 }); + }); + + it("matches a full projection through streamed rows, request changes and answers", () => { + const question = projection.turnItems[0] as QuestionItem; + const resolved = (answers: Record) => ({ + ...projection.runtimeRequests[0]!, + status: "resolved" as const, + answers, + }); + const explicitRequestId = RuntimeRequestId.make("explicit-question"); + const explicitAnswer = { + requestId: explicitRequestId, + answers: { next: "Explicit" }, + attachmentsByQuestionId: {}, + questionTextById: { next: "What should happen next?" }, + }; + const unanswered: QuestionItem = { + ...question, + id: TurnItemId.make("explicit-question-item"), + ordinal: 1, + requestId: explicitRequestId, + }; + const explicit: QuestionItem = { ...unanswered, questionAnswer: explicitAnswer }; + const explicitRequest = { + ...resolved({ next: "From request" }), + id: explicitRequestId, + }; + const stream = assistantItem(2); + const inherited = { + ...localRow({ ...question, id: TurnItemId.make("inherited-question-item") }, 3), + visibility: "inherited" as const, + sourceThreadId: ThreadId.make("parent-thread"), + }; + const turnItems = [question, explicit, stream]; + let current: OrchestrationV2ThreadProjection = { + ...v2Projection, + turnItems, + visibleTurnItems: [...turnItems.map(localRow), inherited], + runtimeRequests: [projection.runtimeRequests[0]!, explicitRequest], + }; + const project = createQuestionHistoryProjector(); + const step = (next: OrchestrationV2ThreadProjection) => { + current = next; + const result = project(next); + expect(result).toEqual(createQuestionHistoryProjector()(next)); + return result; + }; + const answerOf = (rows: ReadonlyArray<{ readonly item: unknown }>, index: number) => + (rows[index]!.item as { readonly questionAnswer?: { readonly answers: unknown } }) + .questionAnswer?.answers; + + expect(step(current)).toBe(current.visibleTurnItems); + expect(step(updated(current, assistantItem(2, "Hello")))).toBe(current.visibleTurnItems); + + // The last duplicate request wins, and every row asking it gets the answer. + const answered = step({ + ...current, + runtimeRequests: [ + projection.runtimeRequests[0]!, + resolved({ next: "Continue" }), + explicitRequest, + ], + }); + expect(answerOf(answered, 0)).toEqual({ next: "Continue" }); + expect(answerOf(answered, 1)).toEqual({ next: "Explicit" }); + expect(answerOf(answered, 3)).toEqual({ next: "Continue" }); + + const whileOverlaid = step(updated(current, assistantItem(2, "Hello there"))); + expect(whileOverlaid[0]).toBe(answered[0]); + expect(whileOverlaid[3]).toBe(answered[3]); + + const changedAnswer = step({ + ...current, + runtimeRequests: [resolved({ next: "Stop" }), explicitRequest], + }); + expect(answerOf(changedAnswer, 0)).toEqual({ next: "Stop" }); + + const reopened = step({ + ...current, + runtimeRequests: [projection.runtimeRequests[0]!, explicitRequest], + }); + expect(reopened).toBe(current.visibleTurnItems); + + // Dropping an explicit answer falls back to its resolved request. + const restored = step(updated(current, unanswered)); + expect(answerOf(restored, 1)).toEqual({ next: "From request" }); + + const retitled: QuestionItem = { + ...unanswered, + questions: [{ ...question.questions[0]!, question: "Now what?" }], + }; + const retitledRows = step(updated(current, retitled)); + expect(retitledRows[1]!.item).toMatchObject({ + questionAnswer: { questionTextById: { next: "Now what?" } }, + }); + }); +}); + it("loads only child approvals and retains their destination across environments", () => { const local = EnvironmentId.make("local"); const remote = EnvironmentId.make("remote"); diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index a010018c5051d..6cbad534cba1d 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -13,6 +13,7 @@ import type { } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { parseThreadKey, threadKey } from "./entities.ts"; +import { getProjectionArrayReplacementIndex } from "./orchestrationV2Projection.ts"; import type { EnvironmentThreadShell } from "./models.ts"; import * as DateTime from "effect/DateTime"; import type { AtomCommandResult } from "./runtime.ts"; @@ -292,54 +293,61 @@ export function createQuestionHistoryProjector< let previousRows: Rows | undefined; let previousRequests: Requests | undefined; let previousResult: Rows | undefined; + let byId = new Map(); const cache = new WeakMap(); - return (projection: { - readonly visibleTurnItems: Rows; - readonly runtimeRequests: Requests; - }): Rows => { - const { visibleTurnItems: rows, runtimeRequests: requests } = projection; - if (rows === previousRows && requests === previousRequests && previousResult) - return previousResult; - const byId = new Map(requests.map((request) => [request.id, request])); + const projectRow = (row: Row): Row => { + const item = row.item; + if (item.type !== "user_input_request" || item.questionAnswer) return row; + const request = byId.get(item.requestId); + if (request?.status !== "resolved" || request.answers === undefined) return row; + const cached = cache.get(row); + const next: Row = + cached?.request === request + ? cached.row + : { + ...row, + item: { + ...item, + questionAnswer: { + requestId: request.id, + answers: request.answers, + attachmentsByQuestionId: {}, + questionTextById: Object.fromEntries( + item.questions.map((question) => [question.id, question.question]), + ), + }, + }, + }; + cache.set(row, { request, row: next }); + return next; + }; + const projectRows = (rows: Rows): Rows => { let result: Row[] | undefined; for (const [index, row] of rows.entries()) { - const item = row.item; - const request = - item.type === "user_input_request" && !item.questionAnswer - ? byId.get(item.requestId) - : undefined; - let next = row; - if ( - item.type === "user_input_request" && - request?.status === "resolved" && - request.answers !== undefined - ) { - const cached = cache.get(row); - next = - cached?.request === request - ? cached.row - : { - ...row, - item: { - ...item, - questionAnswer: { - requestId: request.id, - answers: request.answers, - attachmentsByQuestionId: {}, - questionTextById: Object.fromEntries( - item.questions.map((question) => [question.id, question.question]), - ), - }, - }, - }; - cache.set(row, { request, row: next }); - } + const next = projectRow(row); if (next !== row) result ??= rows.slice(0, index); result?.push(next); } + return result ?? rows; + }; + return (projection: { + readonly visibleTurnItems: Rows; + readonly runtimeRequests: Requests; + }): Rows => { + const { visibleTurnItems: rows, runtimeRequests: requests } = projection; + const sameRequests = requests === previousRequests; + if (rows === previousRows && sameRequests && previousResult) return previousResult; + if (!sameRequests) byId = new Map(requests.map((request) => [request.id, request])); + // Nothing was overlaid and requests are unchanged, so only an exactly replaced row can differ. + const replacedIndex = + sameRequests && previousRows !== undefined && previousResult === previousRows + ? getProjectionArrayReplacementIndex(previousRows, rows) + : undefined; + const unchanged = + replacedIndex !== undefined && projectRow(rows[replacedIndex]!) === rows[replacedIndex]; previousRows = rows; previousRequests = requests; - previousResult = result ?? rows; + previousResult = unchanged ? rows : projectRows(rows); return previousResult; }; } diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 7eb329320acb3..39607575627dd 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -48,7 +48,11 @@ function makeHarness(environmentIds: ReadonlyArray = [environment registry: AtomRegistry.make(), snapshotAtom, catalogValueAtom, - threads: createEnvironmentThreadShellAtoms({ catalogValueAtom, snapshotAtom }), + threads: createEnvironmentThreadShellAtoms({ + catalogValueAtom, + snapshotAtom, + hiddenThreadTagsAtom: () => Atom.make(null), + }), }; } diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 60c454469b551..f5ced761c5665 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -16,6 +16,7 @@ import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; import { presentThreadShell, threadRuntimeIsActive } from "./models.ts"; +import { hiddenThreadKeys, NO_HIDDEN_THREADS } from "./threadTagFilter.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { arrayElementsEqual, @@ -204,6 +205,10 @@ export function createEnvironmentThreadShellAtoms(input: { readonly snapshotAtom: ( environmentId: EnvironmentId, ) => Atom.Atom; + /** Tags whose threads display lists leave out; null while the filter is off. */ + readonly hiddenThreadTagsAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom | null>; }) { // Point reads and aggregate lists share values without keeping an atom alive // for every listed thread. Replaced source objects can be collected. @@ -589,12 +594,67 @@ export function createEnvironmentThreadShellAtoms(input: { return previousThreadShells; }).pipe(Atom.withLabel("environment-thread-shell-list")); + // Point reads and threadShellsAtom stay complete, so code that asks whether a thread + // exists (outboxes, drafts, removals) never mistakes a hidden thread for a missing one. + let previousHiddenThreadKeys: ReadonlySet = NO_HIDDEN_THREADS; + const hiddenThreadKeysAtom = Atom.make((get) => { + const environments = Array.from( + enabledEnvironmentIds(get(input.catalogValueAtom)), + (environmentId) => ({ + environmentId, + hiddenTags: get(input.hiddenThreadTagsAtom(environmentId)), + }), + ); + // Read shells only while some environment hides tags, so streaming ticks cost nothing otherwise. + if (!environments.some((environment) => environment.hiddenTags !== null)) { + previousHiddenThreadKeys = NO_HIDDEN_THREADS; + return NO_HIDDEN_THREADS; + } + const next = hiddenThreadKeys( + environments.map((environment) => ({ + ...environment, + threads: get(environmentThreadsAtom(environment.environmentId)), + })), + ); + if ( + next.size === previousHiddenThreadKeys.size && + [...next].every((key) => previousHiddenThreadKeys.has(key)) + ) { + return previousHiddenThreadKeys; + } + previousHiddenThreadKeys = next; + return next; + }).pipe(Atom.withLabel("environment-hidden-thread-keys")); + + const anyEnvironmentHidingAtom = Atom.make((get) => + Array.from(enabledEnvironmentIds(get(input.catalogValueAtom))).some( + (environmentId) => get(input.hiddenThreadTagsAtom(environmentId)) !== null, + ), + ).pipe(Atom.withLabel("environment-any-hiding-thread-tags")); + + /** threadShellsAtom without the threads the tag filter hides, for pickers and palettes. */ + let previousVisibleThreadShells: ReadonlyArray = []; + const visibleThreadShellsAtom = Atom.make((get) => { + const threads = get(threadShellsAtom); + const hidden = get(hiddenThreadKeysAtom); + if (hidden.size === 0) return threads; + const next = threads.filter( + (thread) => + !hidden.has(threadKey({ environmentId: thread.environmentId, threadId: thread.id })), + ); + if (arrayElementsEqual(previousVisibleThreadShells, next)) return previousVisibleThreadShells; + previousVisibleThreadShells = next; + return next; + }).pipe(Atom.withLabel("environment-visible-thread-shell-list")); + // Lists that arrange threads read this, so a running thread's streaming // ticks reach only what reads its live shell. let previousQuietThreadShells: ReadonlyArray = []; const quietThreadShellsAtom = Atom.make((get) => { const next: EnvironmentThreadShell[] = []; + const hidden = get(hiddenThreadKeysAtom); for (const ref of get(threadRefsAtom)) { + if (hidden.has(threadKey(ref))) continue; const shell = get(quietThreadShellAtomFamily(threadKey(ref))); if (shell !== null) next.push(shell); } @@ -635,12 +695,14 @@ export function createEnvironmentThreadShellAtoms(input: { const navigationThreadShellsAtom = Atom.make((get) => { const next: EnvironmentThreadShell[] = []; const underParent = get(delegatedUnderParentAtom); + const hidden = get(hiddenThreadKeysAtom); for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); for (const thread of get(environmentThreadsAtom(environmentId))) { if ( thread.archivedAt !== null || - isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent) + isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent) || + hidden.has(threadKey({ environmentId, threadId: thread.id })) ) { continue; } @@ -659,6 +721,9 @@ export function createEnvironmentThreadShellAtoms(input: { environmentThreadRefsByProjectAtom, threadRefsAtom, threadShellsAtom, + visibleThreadShellsAtom, + hiddenThreadKeysAtom, + anyEnvironmentHidingAtom, quietThreadShellsAtom, navigationThreadShellsAtom, delegatedUnderParentAtom, diff --git a/packages/client-runtime/src/state/threadTagFilter.test.ts b/packages/client-runtime/src/state/threadTagFilter.test.ts new file mode 100644 index 0000000000000..d38e5386d5243 --- /dev/null +++ b/packages/client-runtime/src/state/threadTagFilter.test.ts @@ -0,0 +1,127 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { threadKey } from "./entities.ts"; +import { hiddenThreadKeys, NO_HIDDEN_THREADS, visibleLinkedThreads } from "./threadTagFilter.ts"; + +const local = EnvironmentId.make("local"); +const linked = EnvironmentId.make("linked"); + +const thread = ( + id: string, + tags: Array, + parent?: { readonly id: string; readonly relationship: "fork" | "subagent" }, + delegatedFrom?: { readonly environmentId: EnvironmentId; readonly id: string }, +) => ({ + id: ThreadId.make(id), + tags, + lineage: { + parentThreadId: parent === undefined ? null : ThreadId.make(parent.id), + relationshipToParent: parent?.relationship ?? null, + rootThreadId: ThreadId.make(parent?.id ?? id), + }, + ...(delegatedFrom === undefined + ? {} + : { + delegatedFrom: { + environmentId: delegatedFrom.environmentId, + threadId: ThreadId.make(delegatedFrom.id), + title: "parent", + }, + }), +}); + +const localThreads = [ + thread("private", ["private"]), + thread("private-subagent", [], { id: "private", relationship: "subagent" }), + thread("private-fork", [], { id: "private", relationship: "fork" }), + thread("plain", []), + thread("plain-private-side-chat", ["private"], { id: "plain", relationship: "subagent" }), +]; +const linkedThreads = [ + thread("delegated-from-private", [], undefined, { environmentId: local, id: "private" }), + thread("linked-client", ["client"]), +]; +const hiddenIds = (localTags: Array | null, linkedTags: Array | null) => + [ + ...hiddenThreadKeys([ + { environmentId: local, threads: localThreads, hiddenTags: localTags }, + { environmentId: linked, threads: linkedThreads, hiddenTags: linkedTags }, + ]), + ].toSorted(); +const key = (environmentId: EnvironmentId, id: string) => + threadKey({ environmentId, threadId: ThreadId.make(id) }); + +describe("hiddenThreadKeys", () => { + it("hides nothing while every filter is off", () => { + expect(hiddenIds(null, null)).toEqual([]); + expect( + hiddenThreadKeys([{ environmentId: local, threads: localThreads, hiddenTags: null }]), + ).toBe(NO_HIDDEN_THREADS); + }); + + it("hides a tagged thread with its subagents and linked tasks, but not its forks", () => { + expect(hiddenIds(["private"], null)).toEqual( + [ + key(local, "private"), + key(local, "private-subagent"), + key(linked, "delegated-from-private"), + ].toSorted(), + ); + }); + + it("judges an archive page against live hidden parents", () => { + const archived = hiddenThreadKeys( + [{ environmentId: linked, threads: [linkedThreads[0]!], hiddenTags: null }], + new Set([key(local, "private")]), + ); + expect([...archived]).toEqual([key(linked, "delegated-from-private")]); + }); + + it("hides a child whose parent is not loaded while any environment hides tags", () => { + const orphan = thread("orphan-task", [], undefined, { environmentId: local, id: "archived" }); + const keys = (localTags: Array | null, linkedTags: Array | null) => [ + ...hiddenThreadKeys([ + { environmentId: local, threads: [], hiddenTags: localTags }, + { environmentId: linked, threads: [orphan], hiddenTags: linkedTags }, + ]), + ]; + expect(keys(["private"], null)).toEqual([key(linked, "orphan-task")]); + expect(keys(null, ["client"])).toEqual([key(linked, "orphan-task")]); + expect(keys(null, null)).toEqual([]); + }); + + it("applies each environment's own tags", () => { + expect(hiddenIds(null, ["client", "private"])).toEqual([key(linked, "linked-client")]); + }); + + it("judges loaded PR rows by visibility and leaves out unloaded rows while hiding", () => { + const hidden = hiddenThreadKeys([ + { environmentId: local, threads: localThreads, hiddenTags: ["private"] }, + ]); + const rows = [ + { id: ThreadId.make("private-subagent") }, + { id: ThreadId.make("plain-private-side-chat") }, + { id: ThreadId.make("off-shell") }, + ]; + const loaded = localThreads.map((entry) => ({ environmentId: local, threadId: entry.id })); + const visible = (hiding: boolean) => + visibleLinkedThreads(rows, local, loaded, hidden, hiding).map((row) => row.id); + expect(visible(true)).toEqual(["plain-private-side-chat"]); + // Another machine hides tags while nothing here is hidden: unloaded rows still stay out. + expect( + visibleLinkedThreads(rows, local, loaded, NO_HIDDEN_THREADS, true).map((row) => row.id), + ).toEqual(["private-subagent", "plain-private-side-chat"]); + expect(visibleLinkedThreads(rows, local, loaded, NO_HIDDEN_THREADS, false)).toBe(rows); + }); + + it("lets a live hidden parent win over the same parent being loaded elsewhere", () => { + const parentKey = key(local, "private"); + const archived = hiddenThreadKeys( + [{ environmentId: linked, threads: [linkedThreads[0]!], hiddenTags: null }], + new Set([parentKey]), + new Set([parentKey]), + ); + expect([...archived]).toEqual([key(linked, "delegated-from-private")]); + }); +}); diff --git a/packages/client-runtime/src/state/threadTagFilter.ts b/packages/client-runtime/src/state/threadTagFilter.ts new file mode 100644 index 0000000000000..78e314445bfb5 --- /dev/null +++ b/packages/client-runtime/src/state/threadTagFilter.ts @@ -0,0 +1,125 @@ +import { + type EnvironmentId, + isListedThreadLineage, + type OrchestrationV2ThreadShell, + type ScopedThreadRef, + type ThreadId, +} from "@t3tools/contracts"; + +import { threadKey } from "./entities.ts"; + +type FilterableThread = Pick< + OrchestrationV2ThreadShell, + "id" | "lineage" | "tags" | "delegatedFrom" +>; + +export interface TagFilteredEnvironment { + readonly environmentId: EnvironmentId; + readonly threads: ReadonlyArray; + /** Tags this environment hides, or null while its filter is off. */ + readonly hiddenTags: ReadonlyArray | null; +} + +export const NO_HIDDEN_THREADS: ReadonlySet = new Set(); + +/** The thread a subagent, side chat or delegated task takes its visibility from, if any. */ +export function visibilityParentKey( + environmentId: EnvironmentId, + thread: Pick, +): string | null { + const parentId = thread.lineage.parentThreadId; + if (!isListedThreadLineage(thread.lineage) && parentId !== null) { + return threadKey({ environmentId, threadId: parentId }); + } + return thread.delegatedFrom === undefined ? null : threadKey(thread.delegatedFrom); +} + +/** + * Pull request linked-thread rows the tag filter leaves visible. Loaded threads use the + * parent-aware hidden set. Rows the shell leaves out carry no lineage, so while any + * environment hides tags they stay out. + */ +export function visibleLinkedThreads( + rows: ReadonlyArray, + environmentId: EnvironmentId, + loadedThreadRefs: ReadonlyArray, + hiddenKeys: ReadonlySet, + hidingAnywhere: boolean, +): ReadonlyArray { + if (!hidingAnywhere && hiddenKeys.size === 0) return rows; + const loaded = new Set(loadedThreadRefs.map((ref) => ref.threadId)); + return rows.filter((row) => + loaded.has(row.id) + ? !hiddenKeys.has(threadKey({ environmentId, threadId: row.id })) + : !hidingAnywhere, + ); +} + +function hasHiddenTag( + tags: ReadonlyArray | undefined, + hiddenTags: ReadonlyArray | null, +): boolean { + return hiddenTags?.some((tag) => tags?.includes(tag) === true) === true; +} + +/** + * Keys of threads the tag filter hides. Children (subagents, side chats, delegated tasks) follow + * their parent; while any environment hides tags, a child whose parent is not loaded stays hidden. + * For a partial list such as an archive page, `alreadyHidden` and `loadedElsewhere` give the + * hidden and loaded keys of live threads, and `hidingAnywhere` the global flag. + */ +export function hiddenThreadKeys( + environments: ReadonlyArray, + alreadyHidden: ReadonlySet = NO_HIDDEN_THREADS, + loadedElsewhere: ReadonlySet = NO_HIDDEN_THREADS, + hidingAnywhere: boolean = environments.some((environment) => environment.hiddenTags !== null), +): ReadonlySet { + if (alreadyHidden.size === 0 && !hidingAnywhere) return NO_HIDDEN_THREADS; + const byKey = new Map< + string, + { thread: FilterableThread; environment: TagFilteredEnvironment } + >(); + for (const environment of environments) { + for (const thread of environment.threads) { + byKey.set(threadKey({ environmentId: environment.environmentId, threadId: thread.id }), { + thread, + environment, + }); + } + } + const hiddenByKey = new Map(); + const isHidden = (key: string): boolean => { + if (alreadyHidden.has(key)) return true; + const known = hiddenByKey.get(key); + if (known !== undefined) return known; + const entry = byKey.get(key); + if (entry === undefined) return false; + const { thread, environment } = entry; + const parentKey = visibilityParentKey(environment.environmentId, thread); + const followsParent = + parentKey !== null && (byKey.has(parentKey) || alreadyHidden.has(parentKey)); + if (parentKey !== null && !followsParent && loadedElsewhere.has(parentKey)) { + hiddenByKey.set(key, false); + return false; + } + if (parentKey !== null && !followsParent && hidingAnywhere) { + hiddenByKey.set(key, true); + return true; + } + if (!followsParent) { + const hidden = hasHiddenTag(thread.tags, environment.hiddenTags); + hiddenByKey.set(key, hidden); + return hidden; + } + // Seed before following the parent so a malformed cycle ends visible. + hiddenByKey.set(key, false); + const hidden = isHidden(parentKey); + hiddenByKey.set(key, hidden); + return hidden; + }; + const hidden = new Set(); + for (const key of byKey.keys()) { + if (isHidden(key)) hidden.add(key); + } + return hidden.size === 0 ? NO_HIDDEN_THREADS : hidden; +} diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index d7c784ae2b4f6..4e876e6bd7ac4 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -97,7 +97,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? threadResumeCompletionMarker: true, threadSnapshotPagination: true, } as never), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8658690be5614..e2f9abbcb87fe 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -85,7 +85,7 @@ function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({} as never), - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 5c6622b5ad9ae..970ec6cd8d821 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1113,3 +1113,4 @@ export * from "./threadFamilies.ts"; export * from "./olderSettledThreads.ts"; export * from "./threadShell.ts"; export * from "./threadState.ts"; +export * from "./threadTagFilter.ts"; diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 29aac2ae98ecf..0650b1f2093c7 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -5,7 +5,9 @@ import { UsageDay, USAGE_CONTRACT_VERSION, type ServerProvider, + type UsageModelFilter, type UsageSummary, + type UsageSummaryInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -39,7 +41,7 @@ afterEach(() => { for (const registry of registries.splice(0)) registry.dispose(); }); -function harness(ids = ["a"]) { +function harness(ids = ["a"], modelFilters: Record = {}) { const registry = AtomRegistry.make(); registries.push(registry); const environments = ids.map((id) => { @@ -65,13 +67,18 @@ function harness(ids = ["a"]) { if (!environment) throw new Error(`Unknown environment: ${environmentId}`); return environment; } + const requested: { environmentId: EnvironmentId; input: UsageSummaryInput }[] = []; const options = { registry, environmentIds: environments.map((entry) => entry.environmentId), input, server: { - usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) => - get(environmentId).query, + usageSummary: (target: { environmentId: EnvironmentId; input: UsageSummaryInput }) => { + requested.push(target); + return get(target.environmentId).query; + }, + usageModelFilterAtom: (environmentId: EnvironmentId) => + Atom.make(modelFilters[environmentId]), refreshUsageRates: { label: "test:rates", requiredScopes: () => [], @@ -86,7 +93,7 @@ function harness(ids = ["a"]) { presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation, }, } satisfies Parameters[0]; - return { registry, environments, refresh: () => refreshUsage(options) }; + return { registry, environments, requested, refresh: () => refreshUsage(options) }; } describe("manual usage refresh", () => { @@ -113,6 +120,23 @@ describe("manual usage refresh", () => { expect(finished).toBe(true); }); + it("rescans each environment with its own configured model filter", async () => { + const modelFilter = { include: [{ provider: "claude" as const, model: "orbit-1" }] }; + const { environments, requested, refresh } = harness(["filtered", "plain"], { + filtered: modelFilter, + }); + const refreshing = refresh(); + for (const entry of environments) { + entry.rates.resolve(AsyncResult.success(pricing)); + entry.scan.resolve(summary); + } + await refreshing; + expect(requested).toEqual([ + { environmentId: "filtered", input: { ...input, modelFilter } }, + { environmentId: "plain", input }, + ]); + }); + it("settles when an environment disconnects during the rescan", async () => { const { registry, diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 7bc582eb7da39..2dea725be1f14 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, ServerProvider, + UsageModelFilter, UsageSummary, UsageSummaryInput, } from "@t3tools/contracts"; @@ -43,6 +44,14 @@ export function cursorKeychainAccessEnvironments< : environments.filter((environment) => environment.needsCursorKeychainAccess); } +/** The Usage tab asks each environment with that environment's configured model filter. */ +export function usageSummaryInput( + input: UsageSummaryInput, + modelFilter: UsageModelFilter | undefined, +): UsageSummaryInput { + return modelFilter === undefined ? input : { ...input, modelFilter }; +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); @@ -86,7 +95,7 @@ export async function refreshUsage({ registry: AtomRegistry.AtomRegistry; server: Pick< ReturnType, - "usageSummary" | "refreshUsageRates" + "usageSummary" | "refreshUsageRates" | "usageModelFilterAtom" >; presentations: Pick, "presentationAtom">; environmentIds: readonly EnvironmentId[]; @@ -94,7 +103,10 @@ export async function refreshUsage({ }): Promise { await Promise.all( environmentIds.map(async (environmentId) => { - const query = server.usageSummary({ environmentId, input }); + const query = server.usageSummary({ + environmentId, + input: usageSummaryInput(input, registry.get(server.usageModelFilterAtom(environmentId))), + }); const presentation = presentations.presentationAtom(environmentId); const controller = new AbortController(); const abortWhenDisconnected = () => { diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index a8913faa75892..7f87890aa1c91 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -4,6 +4,7 @@ import { AuthSourceControlWriteScope, WS_METHODS, VcsProcessExitError, + type VcsListRefsInput, type VcsListRefsResult, type VcsStatusResult, } from "@t3tools/contracts"; @@ -33,6 +34,7 @@ import * as Persistence from "../platform/persistence.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; +import { createGitEnvironmentAtoms } from "./git.ts"; import { commitVcsRefsRefresh, @@ -93,7 +95,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -154,6 +156,99 @@ describe("cached VCS refs", () => { registry.dispose(); }); + it.effect("bypasses the server ref snapshot when a mounted list is invalidated", () => + Effect.scoped( + Effect.gen(function* () { + const requests = yield* Queue.unbounded(); + const client = { + [WS_METHODS.vcsListRefs]: (input: VcsListRefsInput) => + Effect.sync(() => { + Queue.offerUnsafe(requests, input); + return LIVE_REFS; + }), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + setDiscoveredRoutes: () => Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + followStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ); + const atoms = createVcsEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + yield* Effect.acquireRelease( + Effect.sync(() => + registry.mount( + atoms.listRefs({ + environmentId: TARGET.environmentId, + input: { cwd: "/repo", limit: 100 }, + }), + ), + ), + (unmount) => Effect.sync(unmount), + ); + + expect(yield* Queue.take(requests)).toEqual({ cwd: "/repo", limit: 100 }); + + invalidateVcsRefs(registry, TARGET); + + expect(yield* Queue.take(requests)).toEqual({ cwd: "/repo", limit: 100, refresh: true }); + + yield* Effect.acquireRelease( + Effect.sync(() => + registry.mount( + atoms.listRefs({ + environmentId: TARGET.environmentId, + input: { cwd: "/other-repo", limit: 100 }, + }), + ), + ), + (unmount) => Effect.sync(unmount), + ); + expect(yield* Queue.take(requests)).toEqual({ + cwd: "/other-repo", + limit: 100, + refresh: true, + }); + + yield* Effect.acquireRelease( + Effect.sync(() => + registry.mount( + atoms.listRefs({ + environmentId: TARGET.environmentId, + input: { cwd: "/other-repo", query: "release", limit: 100 }, + }), + ), + ), + (unmount) => Effect.sync(unmount), + ); + expect(yield* Queue.take(requests)).toEqual({ + cwd: "/other-repo", + query: "release", + limit: 100, + }); + }), + ), + ); + it.effect("does not repersist a refresh superseded by ref invalidation", () => Effect.scoped( Effect.gen(function* () { @@ -223,6 +318,8 @@ describe("cached VCS refs", () => { const client = { [WS_METHODS.vcsPull]: () => Effect.fail(expectedError), [WS_METHODS.vcsRefreshStatus]: () => Effect.void, + [WS_METHODS.gitPreparePullRequestThread]: () => + Effect.succeed({ branch: "feature/pr", worktreePath: null }), } as unknown as WsRpcProtocolClient; const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, @@ -279,6 +376,17 @@ describe("cached VCS refs", () => { expect(AsyncResult.isSuccess(refreshResult)).toBe(true); expect(yield* Ref.get(clears)).toBe(2); expect(registry.get(vcsRefsCacheStateAtom(TARGET)).revision).toBe(2); + + const prepareResult = yield* Effect.promise(() => + createGitEnvironmentAtoms(runtime).preparePullRequestThread.run(registry, { + environmentId: TARGET.environmentId, + input: { cwd: "/repo", reference: "42", mode: "local" }, + }), + ); + + expect(AsyncResult.isSuccess(prepareResult)).toBe(true); + expect(yield* Ref.get(clears)).toBe(3); + expect(registry.get(vcsRefsCacheStateAtom(TARGET)).revision).toBe(3); }), ), ); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index e05ec4a2c8eb6..ef508f2126c7f 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -33,6 +33,9 @@ import { withVcsRefsPersistenceLock, } from "./vcsRefInvalidation.ts"; +// Share freshness across search and pagination keys, but keep it local to this registry. +const serverRefsRevisionAtom = Atom.family((_key: string) => Atom.make(0).pipe(Atom.keepAlive)); + const OFFLINE_BRANCH_LIST_LIMIT = 100; const VCS_REFS_IDLE_TTL_MS = 30_000; const VCS_STATUS_IDLE_TTL_MS = 10_000; @@ -143,9 +146,17 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange ) : Option.none(); const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () { - const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe( - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + const revisionAtom = serverRefsRevisionAtom( + `${environmentId.length}:${environmentId}${input.cwd}`, ); + const bypassServerSnapshot = + expectedRevision !== undefined && + registry !== undefined && + expectedRevision > registry.get(revisionAtom); + const refs = yield* request( + WS_METHODS.vcsListRefs, + bypassServerSnapshot ? { ...input, refresh: true } : input, + ).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); const persist = cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( Effect.catch((error) => Effect.logWarning("Could not persist cached Git refs.").pipe( @@ -168,6 +179,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange expectedRevision, persist: useCache, }); + if (committed && bypassServerSnapshot) registry.set(revisionAtom, expectedRevision); return committed ? Option.some(refs) : Option.none(); }); diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index b454eda9b0a77..0c599358e7b9c 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -86,7 +86,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0a8e0181598e9..1eda82aab0e1d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -50,6 +50,7 @@ export * from "./peerLink.ts"; export * from "./threadMetadataMcp.ts"; export * from "./threadPullRequest.ts"; export * from "./threadSearch.ts"; +export * from "./threadTags.ts"; export * from "./threadTitle.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8e42ee9b50393..f72ac225fd6f1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1012,6 +1012,13 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ data: Schema.Uint8Array, }); +export const DesktopMediaReadSchema = Schema.Struct({ + status: Schema.Number, + mimeType: Schema.String, + data: Schema.Uint8Array, +}); +export type DesktopMediaRead = typeof DesktopMediaReadSchema.Type; + export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; @@ -1100,6 +1107,8 @@ export interface DesktopBridge { receiveProviderAuthCallback: (authorizationUrl: string) => Promise; cancelProviderAuthCallback: (authorizationUrl: string) => Promise; openExternal: (url: string) => Promise; + /** Reads http(s) media in the main process, outside renderer CORS and CSP; null on network failure. */ + readMedia: (url: string) => Promise; openSystemSettings: (pane: SystemSettingsPane) => Promise; checkSystemPermission: (pane: SystemSettingsPane) => Promise; probeRemoteEditors: () => Promise; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1a04cc61c887a..1a2a519e1abc7 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -119,6 +119,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const FX_DRIVER_KIND = ProviderDriverKind.make("fx"); +const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); @@ -147,6 +148,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [FX_DRIVER_KIND]: "fx", + [KIRO_DRIVER_KIND]: "Kiro", [MUSE_DRIVER_KIND]: "Muse Code", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1b305f0dcf28f..5e41382a6536e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -45,6 +45,7 @@ import { import { AuthMcpClientAccess } from "./auth.ts"; import { ChatAttachment } from "./chatAttachment.ts"; import { ThreadGuestShare } from "./guestShare.ts"; +import { StoredThreadTags, ThreadTag } from "./threadTags.ts"; import { OrchestrationSearchThreadsInput, OrchestrationSearchThreadsResult, @@ -286,6 +287,8 @@ export const OrchestrationV2TurnCapabilities = Schema.Struct({ supportsActiveSteering: Schema.Boolean, // Some native steering mechanisms cancel pending tools before consuming the message. activeSteeringInterruptsTools: Schema.optional(Schema.Boolean), + // Some take text only, so a steer with attachments restarts the turn instead. + activeSteeringAcceptsAttachments: Schema.optional(Schema.Boolean), supportsSteeringByInterruptRestart: Schema.Boolean, supportsQueuedMessages: Schema.Boolean, terminalStatusQuality: Schema.Literals(["strong", "weak", "none"]), @@ -489,6 +492,8 @@ export const OrchestrationV2AppThread = Schema.Struct({ autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** Absent on threads written before tags existed; read as empty. */ + tags: Schema.optional(StoredThreadTags), lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), @@ -1487,6 +1492,13 @@ export const OrchestrationV2UserMessageInputIntent = Schema.Literals([ export type OrchestrationV2UserMessageInputIntent = typeof OrchestrationV2UserMessageInputIntent.Type; +/** A same-harness model or effort change that took effect with this message. */ +export const OrchestrationV2ModelSwitch = Schema.Struct({ + from: ModelSelection, + to: ModelSelection, +}); +export type OrchestrationV2ModelSwitch = typeof OrchestrationV2ModelSwitch.Type; + const OrchestrationV2TurnItemBaseFields = { /** Wire-only cursor for collapsed work; persisted items keep their complete history. */ deferredWork: Schema.optionalKey( @@ -1608,6 +1620,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ textOmitted: DedupedBodyMarker, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), + modelSwitch: Schema.optional(OrchestrationV2ModelSwitch), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -1876,6 +1889,7 @@ export const OrchestrationV2DomainEvent = Schema.Union([ "thread.unpinned", "thread.pin-reordered", "thread.active-reordered", + "thread.tags-updated", "thread.visited", "thread.marked-unread", "thread.metadata-updated", @@ -2128,6 +2142,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** Absent on threads written before tags existed; read as empty. */ + tags: Schema.optional(StoredThreadTags), lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), opens: Schema.optional(LenientThreadOpens), titleRegeneration: Schema.optional( @@ -2438,6 +2454,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ text: Schema.String, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), + modelSwitch: Schema.optional(OrchestrationV2ModelSwitch), }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, @@ -2756,6 +2773,7 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ "thread.unpinned", "thread.pin-reordered", "thread.active-reordered", + "thread.tags-updated", "thread.visited", "thread.marked-unread", "thread.pull-request-synced", @@ -2997,6 +3015,13 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, }), + Schema.Struct({ + type: Schema.Literal("thread.tag.set"), + commandId: CommandId, + threadId: ThreadId, + tag: ThreadTag, + tagged: Schema.Boolean, + }), Schema.Struct({ type: Schema.Literal("thread.pin.reorder"), commandId: CommandId, @@ -3581,6 +3606,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getOlderSettledThreads: "orchestration.getOlderSettledThreads", getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", + getNativeChildWork: "orchestration.getNativeChildWork", stopWorkflow: "orchestration.stopWorkflow", forceStopThread: "orchestration.forceStopThread", getTurnItem: "orchestration.getTurnItem", @@ -4020,6 +4046,53 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({ export type OrchestrationV2GetWorkflowScriptResult = typeof OrchestrationV2GetWorkflowScriptResult.Type; +/** Names a provider-native subagent's child thread. */ +export const OrchestrationV2GetNativeChildWorkInput = Schema.Struct({ + threadId: ThreadId, +}); +export type OrchestrationV2GetNativeChildWorkInput = + typeof OrchestrationV2GetNativeChildWorkInput.Type; + +export const OrchestrationV2NativeChildStep = Schema.Struct({ + index: NonNegativeInt, + type: Schema.String, + status: Schema.NullOr(Schema.String), + text: Schema.NullOr(Schema.String), + toolCalls: Schema.Array(Schema.Struct({ name: Schema.String, input: Schema.String })), +}); +export type OrchestrationV2NativeChildStep = typeof OrchestrationV2NativeChildStep.Type; + +/** Read-only: native children cannot take messages. */ +export const OrchestrationV2NativeChildWork = Schema.Struct({ + conversationId: TrimmedNonEmptyString, + role: Schema.String, + prompt: Schema.String, + /** Null when the provider has no readable transcript for this child. */ + steps: Schema.NullOr(Schema.Array(OrchestrationV2NativeChildStep)), + /** Earlier steps were left out to keep the read bounded. */ + truncated: Schema.Boolean, +}); +export type OrchestrationV2NativeChildWork = typeof OrchestrationV2NativeChildWork.Type; + +/** Empty when the provider's records do not prove which children the subagent launched. */ +export const OrchestrationV2GetNativeChildWorkResult = Schema.Struct({ + children: Schema.Array(OrchestrationV2NativeChildWork), +}); +export type OrchestrationV2GetNativeChildWorkResult = + typeof OrchestrationV2GetNativeChildWorkResult.Type; + +export class OrchestrationV2GetNativeChildWorkError extends Schema.TaggedError()( + "OrchestrationV2GetNativeChildWorkError", + { + threadId: ThreadId, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return "Failed to read the subagent's work."; + } +} + const WORKFLOW_FILE_ERROR_MESSAGES = { "invalid-path": "Workflow files must be absolute paths with the expected extension.", "root-unavailable": "Workflow file root unavailable.", @@ -4199,6 +4272,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2GetWorkflowScriptInput, output: OrchestrationV2GetWorkflowScriptResult, }, + getNativeChildWork: { + input: OrchestrationV2GetNativeChildWorkInput, + output: OrchestrationV2GetNativeChildWorkResult, + }, getTurnItem: { input: OrchestrationV2GetTurnItemInput, output: OrchestrationV2GetTurnItemResult, diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 6618646f1f4fb..7defee76aaca6 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -1,7 +1,13 @@ import { Schema } from "effect"; import { describe, expect, it } from "vite-plus/test"; -import { PreviewAutomationHost, PreviewAutomationStatus } from "./previewAutomation.ts"; +import { EnvironmentId, ThreadId } from "./baseSchemas.ts"; +import { + PreviewAutomationHost, + PreviewAutomationInvalidSelectorError, + PreviewAutomationStatus, +} from "./previewAutomation.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); @@ -42,3 +48,38 @@ describe("PreviewAutomationStatus", () => { ).toEqual({ width: 412, height: 915 }); }); }); + +describe("PreviewAutomationInvalidSelectorError", () => { + const invalidSelector = (selectorKind: "locator" | "selector") => + new PreviewAutomationInvalidSelectorError({ + operation: "click", + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "session-1", + providerInstanceId: ProviderInstanceId.make("claude"), + clientId: "desktop", + connectionId: "connection-1", + requestId: "request-1", + timeoutMs: 15_000, + remoteTag: "Error", + remoteMessageLength: 0, + cause: null, + selectorKind, + selectorLength: 42, + }); + + it("tells agents which locator forms are accepted and where CSS goes", () => { + const { message } = invalidSelector("locator"); + expect(message).toContain("aria-ref="); + expect(message).toMatch(/role/); + expect(message).toMatch(/text/); + expect(message).toMatch(/CSS[^.]*\bselector\b/); + }); + + it("points an invalid CSS selector at the locator alternative", () => { + const { message } = invalidSelector("selector"); + expect(message).toMatch(/CSS/); + expect(message).toMatch(/\blocator\b/); + expect(message).toContain("aria-ref="); + }); +}); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index cbf8ac60b2690..1790a4797db29 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -376,7 +376,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedError from the latest snapshot or a Playwright role/text selector; put CSS in selector." + : "selector takes CSS; use locator for aria-ref=, role, or text targets."; + return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters). ${guidance}`; } return `Preview automation ${this.operation} received an invalid selector.`; } diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 4740a9fd0ccf7..4769bfd7955c7 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -60,6 +60,14 @@ const TurnTokenUsageCommonFields = { cacheCreationTokens: Schema.optional(NonNegativeInt), reasoningTokens: Schema.optional(NonNegativeInt), hasSubagents: Schema.Boolean, + // Native model ids the provider reported for the main agent's work. Absent means unknown. + models: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), + // The provider ran a local command; its counters describe that result, not model work. + localCommand: Schema.optional(Schema.Boolean), + // Provider-ledger counts beyond the main agent, such as native subagents and compaction. + otherUsage: Schema.optional( + Schema.Struct({ inputTokens: NonNegativeInt, outputTokens: NonNegativeInt }), + ), }; export const TurnTokenUsage = Schema.Union([ Schema.Struct({ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d5f41e0e67fca..4af79f3396ce4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -267,6 +267,7 @@ import { import { ORCHESTRATION_V2_WS_METHODS, OrchestrationGetWorkflowScriptError, + OrchestrationV2GetNativeChildWorkError, OrchestrationV2StopWorkflowError, OrchestrationV2ForceStopThreadError, OrchestrationV2DispatchCommandError, @@ -1915,6 +1916,15 @@ const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( }, ); +const WsOrchestrationV2GetNativeChildWorkRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.getNativeChildWork, + { + payload: OrchestrationV2RpcSchemas.getNativeChildWork.input, + success: OrchestrationV2RpcSchemas.getNativeChildWork.output, + error: Schema.Union([OrchestrationV2GetNativeChildWorkError, EnvironmentAuthorizationError]), + }, +); + const WsOrchestrationV2GetTurnItemRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnItem, { payload: OrchestrationV2RpcSchemas.getTurnItem.input, success: OrchestrationV2RpcSchemas.getTurnItem.output, @@ -2366,6 +2376,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeResourceTelemetryRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, + WsOrchestrationV2GetNativeChildWorkRpc, WsOrchestrationV2StopWorkflowRpc, WsOrchestrationV2ForceStopThreadRpc, WsOrchestrationV2GetTurnItemRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 47798c78c5d7c..6a0b90ed24098 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -141,6 +141,26 @@ describe("ServerSettings usage price overrides", () => { expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow(); } }); + + it("ignores an invalid model filter without dropping the other settings", () => { + const valid = { include: [{ provider: "newer-provider", model: "orbit-1" }] }; + expect(decodeServerSettings({ usageModelFilter: valid }).usageModelFilter).toEqual(valid); + const settings = decodeServerSettings({ + usageModelFilter: { exclude: [{ model: "" }] }, + usageModelAliases: { "drift-9": "orbit-1" }, + }); + expect(settings.usageModelFilter).toBeUndefined(); + expect(settings.usageModelAliases).toEqual({ "drift-9": "orbit-1" }); + }); +}); + +it("drops a hand-edited non-string hidden tag and keeps the rest of the settings", () => { + const settings = decodeServerSettings({ + hiddenThreadTags: ["private", 123], + hideTaggedThreads: true, + }); + expect(settings.hiddenThreadTags).toEqual(["private"]); + expect(settings.hideTaggedThreads).toBe(true); }); describe("ServerSettings secret providers", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f4ad157e61d5c..7d2b635a6acbc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -20,6 +20,7 @@ import { TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; +import { UsageModelFilter } from "./usage.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; @@ -1326,6 +1327,18 @@ export const ServerSettings = Schema.Struct({ snoozePresets: SnoozePresetSettings.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_PRESETS)), ), + /** Clients hide threads with these tags while `hideTaggedThreads` is on. Bad entries drop alone, so a hand edit cannot reset settings. */ + hiddenThreadTags: Schema.Array(Schema.Unknown).pipe( + Schema.decodeTo( + Schema.Array(Schema.String), + SchemaTransformation.transform, ReadonlyArray>({ + decode: (values) => values.filter((value) => typeof value === "string"), + encode: (values) => values, + }), + ), + Schema.withDecodingDefault(Effect.succeed([])), + ), + hideTaggedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Beta: guest links for this machine's threads. Off refuses new links and pauses existing ones. */ guestSharingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** @@ -1434,6 +1447,9 @@ export const ServerSettings = Schema.Struct({ usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + + /** Sent by the Usage tab with each summary request to this environment; edit in settings.json. Invalid values are ignored. */ + usageModelFilter: ForwardCompatibleOptional(UsageModelFilter), }); export type ServerSettings = typeof ServerSettings.Type; @@ -1449,7 +1465,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un // Must match each driver's enabled decoding default. const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( - ["cursor", "grok", "fx", "muse", "pi", "opencode", "antigravity"].map((driver) => + ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity"].map((driver) => ProviderDriverKind.make(driver), ), ); @@ -1738,6 +1754,8 @@ export const ServerSettingsPatch = Schema.Struct({ autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozePresets: Schema.optionalKey(SnoozePresetSettings), + hiddenThreadTags: Schema.optionalKey(Schema.Array(Schema.String)), + hideTaggedThreads: Schema.optionalKey(Schema.Boolean), guestSharingEnabled: Schema.optionalKey(Schema.Boolean), linkedEnvironmentsEnabled: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( diff --git a/packages/contracts/src/settingsSecretValue.ts b/packages/contracts/src/settingsSecretValue.ts index 101c40998b6f0..97558c82d574f 100644 --- a/packages/contracts/src/settingsSecretValue.ts +++ b/packages/contracts/src/settingsSecretValue.ts @@ -26,26 +26,68 @@ export function acceptsSecretReference(schema: Schema.Top): boolean { return Schema.resolveAnnotationsKey(schema)?.settingsSecretReference === true; } -/** Fields of a settings struct typed `SettingsSecretValue`; any other schema has none. */ -function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { +const declaresSecretReference = (ast: SchemaAST.AST) => + ast.context?.annotations?.settingsSecretReference === true; + +interface SecretReferenceField { + readonly field: string; + readonly keyed: boolean; +} + +/** + * Fields of a settings struct typed `SettingsSecretValue`, or a record of it such as one value + * per host; any other schema has none. + */ +function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { if (!SchemaAST.isObjects(schema.ast)) return []; - return schema.ast.propertySignatures.flatMap((property) => - property.type.context?.annotations?.settingsSecretReference === true && - typeof property.name === "string" - ? [property.name] - : [], - ); + return schema.ast.propertySignatures.flatMap(({ name, type }) => { + if (typeof name !== "string") return []; + if (declaresSecretReference(type)) return [{ field: name, keyed: false }]; + return SchemaAST.isObjects(type) && + type.indexSignatures.some((signature) => declaresSecretReference(signature.type)) + ? [{ field: name, keyed: true }] + : []; + }); } -/** Each declared reference field of `settings` that holds a reference, in field order. */ +/** Where a reference sits: a field, or one key of a record field. */ +export interface SettingsSecretSlot { + readonly field: string; + readonly key: string | undefined; +} + +interface SettingsSecretReference extends SettingsSecretSlot { + readonly reference: SecretReference; +} + +/** Each declared reference slot of `settings` that holds a reference, in field order. */ export function secretReferencesInSettings( schema: Schema.Top, settings: unknown, -): ReadonlyArray<{ readonly field: string; readonly reference: SecretReference }> { +): ReadonlyArray { if (settings === null || typeof settings !== "object") return []; const values = settings as Readonly>; - return secretReferenceSettingsFields(schema).flatMap((field) => { - const value = values[field]; - return isSecretReference(value) ? [{ field, reference: value }] : []; - }); + return secretReferenceSettingsFields(schema).flatMap( + ({ field, keyed }) => { + const value = values[field]; + if (!keyed) + return isSecretReference(value) ? [{ field, key: undefined, reference: value }] : []; + if (value === null || typeof value !== "object") return []; + return Object.entries(value).flatMap(([key, entry]) => + isSecretReference(entry) ? [{ field, key, reference: entry }] : [], + ); + }, + ); +} + +/** `settings` with each slot set to its value, keeping the other keys of a record field. */ +export function withSettingsSecrets>>( + settings: Settings, + slots: ReadonlyArray, +): Settings { + const next: Record = { ...settings }; + for (const { field, key, value } of slots) { + next[field] = key === undefined ? value : { ...(next[field] as object), [key]: value }; + } + return next as Settings; } diff --git a/packages/contracts/src/threadTags.ts b/packages/contracts/src/threadTags.ts new file mode 100644 index 0000000000000..ec6d8285c1aed --- /dev/null +++ b/packages/contracts/src/threadTags.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** Tags match by exact spelling and case after trimming. */ +export const ThreadTag = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.makeFilter((value) => + /[\p{Cc}]/u.test(value) ? "A thread tag must be a single line of text." : true, + ), +); +export type ThreadTag = typeof ThreadTag.Type; + +/** Tags read back from storage or a peer. Lenient so a later tag rule never fails a whole thread. */ +export const StoredThreadTags = Schema.Array(Schema.String).pipe( + Schema.catchDecoding(() => Effect.succeedSome([])), +); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 8e34c10eb885e..7f5a609eee20a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -108,6 +108,23 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; +/** + * Matches a provider's original model ID, before usage model aliases apply. Omit provider to + * match any; a string provider lets older clients carry selectors for providers they lack. + */ +export const UsageModelSelector = Schema.Struct({ + provider: Schema.optional(TrimmedNonEmptyString), + model: TrimmedNonEmptyString, +}); +export type UsageModelSelector = typeof UsageModelSelector.Type; + +/** Keeps records matching `include` (all when omitted) and not matching `exclude`. */ +export const UsageModelFilter = Schema.Struct({ + include: Schema.optional(Schema.Array(UsageModelSelector)), + exclude: Schema.optional(Schema.Array(UsageModelSelector)), +}); +export type UsageModelFilter = typeof UsageModelFilter.Type; + export const UsageSummaryInput = Schema.Struct({ sinceDay: UsageDay, untilDay: UsageDay, @@ -117,6 +134,7 @@ export const UsageSummaryInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), untilTime: Schema.optional(TrimmedNonEmptyString), awaitRefresh: Schema.optional(Schema.Boolean), + modelFilter: Schema.optional(UsageModelFilter), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; @@ -130,6 +148,8 @@ export const UsageSummary = Schema.Struct({ sources: ForwardCompatibleArray(UsageSource), pricing: UsagePricing, scanDurationMs: NonNegativeInt, + /** The filter the server applied; absent means unfiltered, including servers that predate filters. */ + modelFilter: Schema.optional(UsageModelFilter), }); export type UsageSummary = typeof UsageSummary.Type; diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 8f9a45bb9293c..86c0dc7e662da 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -170,6 +170,22 @@ const DefinitionOverrides: Record = { }, }; +// Codex sends error codes this ref does not list yet (e.g. `modelNotFound`). A closed enum +// fails the whole notification decode, which drops a failed `turn/completed` and leaves the turn running. +const OpenStringEnumDefinitions = new Set(["CodexErrorInfo"]); + +function openStringEnum(schema: Schema.Json): Schema.Json { + if (!isJsonSchemaNode(schema) || !Array.isArray(schema.oneOf)) return schema; + return { + ...schema, + oneOf: schema.oneOf.map((variant: Schema.Json) => + isJsonSchemaNode(variant) && variant.type === "string" && Array.isArray(variant.enum) + ? { type: "string" } + : variant, + ), + }; +} + const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { const path = yield* Path.Path; const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated"); @@ -662,7 +678,10 @@ const generateFiles = Effect.fn("generateFiles")(function* () { for (const [definitionName, definitionSchema] of Object.entries(parsed.definitions ?? {})) { aggregateSchemas[localDefinitionNames.get(definitionName)!] = pipe( rewriteExternalRefs( - DefinitionOverrides[definitionName] ?? definitionSchema, + DefinitionOverrides[definitionName] ?? + (OpenStringEnumDefinitions.has(definitionName) + ? openStringEnum(definitionSchema) + : definitionSchema), localDefinitionNames, file.namespace, exportNameByQualifiedName, diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index 90869b56aa89d..50fa95918b430 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -17949,21 +17949,7 @@ export const PermissionsRequestApprovalResponse__FileSystemSpecialPath = Schema. ).annotate({ identifier: "PermissionsRequestApprovalResponse__FileSystemSpecialPath" }); export type ServerNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -17975,23 +17961,7 @@ export type ServerNotification__CodexErrorInfo = }; export const ServerNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -20035,21 +20005,7 @@ export const V2ConfigWriteResponse__ConfigLayerSource = Schema.Union( ).annotate({ identifier: "V2ConfigWriteResponse__ConfigLayerSource" }); export type V2ErrorNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -20061,23 +20017,7 @@ export type V2ErrorNotification__CodexErrorInfo = }; export const V2ErrorNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -22491,21 +22431,7 @@ export const V2RawResponseItemCompletedNotification__ConfigurationReasoning = Sc }); export type V2ReviewStartResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -22517,23 +22443,7 @@ export type V2ReviewStartResponse__CodexErrorInfo = }; export const V2ReviewStartResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -23029,21 +22939,7 @@ export const V2ThreadForkResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadForkResponse__ThreadStatus" }); export type V2ThreadForkResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -23055,23 +22951,7 @@ export type V2ThreadForkResponse__CodexErrorInfo = }; export const V2ThreadForkResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -23679,21 +23559,7 @@ export const V2ThreadListResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadListResponse__ThreadStatus" }); export type V2ThreadListResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -23705,23 +23571,7 @@ export type V2ThreadListResponse__CodexErrorInfo = }; export const V2ThreadListResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -24088,21 +23938,7 @@ export const V2ThreadMetadataUpdateResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadMetadataUpdateResponse__ThreadStatus" }); export type V2ThreadMetadataUpdateResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -24114,23 +23950,7 @@ export type V2ThreadMetadataUpdateResponse__CodexErrorInfo = }; export const V2ThreadMetadataUpdateResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -24502,21 +24322,7 @@ export const V2ThreadReadResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadReadResponse__ThreadStatus" }); export type V2ThreadReadResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -24528,23 +24334,7 @@ export type V2ThreadReadResponse__CodexErrorInfo = }; export const V2ThreadReadResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -25270,21 +25060,7 @@ export const V2ThreadResumeResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadResumeResponse__ThreadStatus" }); export type V2ThreadResumeResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -25296,23 +25072,7 @@ export type V2ThreadResumeResponse__CodexErrorInfo = }; export const V2ThreadResumeResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -25641,21 +25401,7 @@ export const V2ThreadRevertResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadRevertResponse__ThreadStatus" }); export type V2ThreadRevertResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -25667,23 +25413,7 @@ export type V2ThreadRevertResponse__CodexErrorInfo = }; export const V2ThreadRevertResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -26181,21 +25911,7 @@ export const V2ThreadStartedNotification__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadStartedNotification__ThreadStatus" }); export type V2ThreadStartedNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -26207,23 +25923,7 @@ export type V2ThreadStartedNotification__CodexErrorInfo = }; export const V2ThreadStartedNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -26686,21 +26386,7 @@ export const V2ThreadStartResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadStartResponse__ThreadStatus" }); export type V2ThreadStartResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -26712,23 +26398,7 @@ export type V2ThreadStartResponse__CodexErrorInfo = }; export const V2ThreadStartResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -27016,21 +26686,7 @@ export const V2ThreadTokenUsageUpdatedNotification__ThreadTokenUsage = Schema.St }).annotate({ identifier: "V2ThreadTokenUsageUpdatedNotification__ThreadTokenUsage" }); export type V2ThreadTurnsListResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -27042,23 +26698,7 @@ export type V2ThreadTurnsListResponse__CodexErrorInfo = }; export const V2ThreadTurnsListResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -27429,21 +27069,7 @@ export const V2ThreadUnarchiveResponse__ThreadStatus = Schema.Union( ).annotate({ identifier: "V2ThreadUnarchiveResponse__ThreadStatus" }); export type V2ThreadUnarchiveResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -27455,23 +27081,7 @@ export type V2ThreadUnarchiveResponse__CodexErrorInfo = }; export const V2ThreadUnarchiveResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -27760,21 +27370,7 @@ export const V2ThreadUnarchiveResponse__CollabAgentState = Schema.Struct({ }).annotate({ identifier: "V2ThreadUnarchiveResponse__CollabAgentState" }); export type V2TurnCompletedNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -27786,23 +27382,7 @@ export type V2TurnCompletedNotification__CodexErrorInfo = }; export const V2TurnCompletedNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -28101,21 +27681,7 @@ export const V2TurnPlanUpdatedNotification__TurnPlanStep = Schema.Struct({ }).annotate({ identifier: "V2TurnPlanUpdatedNotification__TurnPlanStep" }); export type V2TurnStartedNotification__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -28127,23 +27693,7 @@ export type V2TurnStartedNotification__CodexErrorInfo = }; export const V2TurnStartedNotification__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( @@ -28569,21 +28119,7 @@ export const V2TurnStartParams__SandboxPolicy = Schema.Union( ).annotate({ identifier: "V2TurnStartParams__SandboxPolicy" }); export type V2TurnStartResponse__CodexErrorInfo = - | "contextWindowExceeded" - | "sessionBudgetExceeded" - | "usageLimitExceeded" - | "rateLimitExceeded" - | "flexUnavailable" - | "serverOverloaded" - | "cyberPolicy" - | "misalignmentPolicyViolation" - | "tooManyDenials" - | "internalServerError" - | "unauthorized" - | "badRequest" - | "threadRollbackFailed" - | "sandboxError" - | "other" + | string | { readonly httpConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamConnectionFailed: { readonly httpStatusCode?: number | null } } | { readonly responseStreamDisconnected: { readonly httpStatusCode?: number | null } } @@ -28595,23 +28131,7 @@ export type V2TurnStartResponse__CodexErrorInfo = }; export const V2TurnStartResponse__CodexErrorInfo = Schema.Union( [ - Schema.Literals([ - "contextWindowExceeded", - "sessionBudgetExceeded", - "usageLimitExceeded", - "rateLimitExceeded", - "flexUnavailable", - "serverOverloaded", - "cyberPolicy", - "misalignmentPolicyViolation", - "tooManyDenials", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other", - ]), + Schema.String, Schema.Struct({ httpConnectionFailed: Schema.Struct({ httpStatusCode: Schema.optionalKey( diff --git a/packages/effect-codex-app-server/src/schema.ts b/packages/effect-codex-app-server/src/schema.ts index 2d5349752a00d..e9acee3bb9e91 100644 --- a/packages/effect-codex-app-server/src/schema.ts +++ b/packages/effect-codex-app-server/src/schema.ts @@ -1,3 +1,8 @@ export * from "./_generated/schema.gen.ts"; export * from "./_generated/meta.gen.ts"; export * from "./_generated/namespaces.gen.ts"; + +// Mirrors codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS, which the +// generated schema does not carry. Codex counts Unicode scalar values (Rust +// `chars()`), not UTF-16 code units. +export const MAX_THREAD_GOAL_OBJECTIVE_CHARS = 4_000; diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index cb1fc4a27bf15..5dd7182d29871 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { @@ -58,7 +59,10 @@ function layerResolver( ) { return Layer.mergeAll( NodeServices.layer, - TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), + NodeCrypto.layer, + TestProviderHost.layer().pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), Layer.succeed(HostProcess.Platform, "linux"), Layer.succeed(HostProcess.Architecture, "x64"), Layer.succeed(HostProcess.Environment, environment), @@ -759,7 +763,10 @@ describe("AcpRegistrySupport", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, - TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), + NodeCrypto.layer, + TestProviderHost.layer().pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), ), ), Effect.provideService(HostProcess.Platform, "linux"), @@ -1765,6 +1772,6 @@ describe("acpRegistryManagedBinaryDirectories", () => { architecture: "x64", }); expect(missing).toEqual([]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped), ); }); diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts index c0065ad5050fd..90e6b42d2a7f9 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts @@ -23,7 +23,8 @@ import { import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -631,15 +632,17 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct AcpRegistryCatalog["Service"], never, | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom + | Digest.Digest | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | ProviderHost.ProviderHost > { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digester = yield* Digest.Digest; const sha256Hex = (data: Uint8Array) => - crypto.digest("SHA-256", data).pipe(Effect.map(Hex.encode), Effect.orDie); + digester.digest(Digest.sha256, data).pipe(Effect.map(Hex.encode), Effect.orDie); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; @@ -776,7 +779,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct .pipe(Effect.flatMap(decodeRegistryText), Effect.option); const writeRegistryCache = (text: string) => - crypto.randomUUIDv4().pipe( + random.uuidV4().pipe( Effect.flatMap((id) => { const temporaryPath = `${registryCachePath}.${process.pid}-${id}.tmp`; return fileSystem diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 4a3fb1ecd5942..1b8b4240fd00e 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -20,7 +20,7 @@ import { readCustomModelEntries } from "@t3tools/shared/model"; import { AcpRegistrySettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -77,7 +77,7 @@ export interface AcpRegistryAdapterV2Options { ) => Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope >; readonly continuationRequests?: Parameters[0]["continuationRequests"]; readonly assertComplete?: Effect.Effect; @@ -139,7 +139,7 @@ function makeAcpRegistryRuntime( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope > => Effect.gen(function* () { const { processEnvironment, ...runtimeInput } = input; @@ -310,7 +310,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu export type AcpRegistryAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | AcpRegistrySupport.AcpRegistryCatalog | IdAllocator.IdAllocatorV2 diff --git a/packages/provider-acp-registry/src/server/auth.test.ts b/packages/provider-acp-registry/src/server/auth.test.ts index 47ce8cabb188b..b81e2e3fba73e 100644 --- a/packages/provider-acp-registry/src/server/auth.test.ts +++ b/packages/provider-acp-registry/src/server/auth.test.ts @@ -1,4 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, it } from "@effect/vitest"; import { ProviderInstanceId, type ProviderAuthState } from "@t3tools/contracts"; import { AcpRegistrySettings } from "../settings.ts"; @@ -230,7 +232,7 @@ it.effect("keeps local credentials separate from a matching registry agent ID", owner: "provider", key: "acp:local:test-agent", }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -264,7 +266,7 @@ it.effect( assert.deepEqual(h.closed, [1, 2]); yield* h.controller.logout(Effect.void); assert.deepEqual(h.changed, [true, false]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -308,7 +310,7 @@ it.effect( yield* h.phase("succeeded"); assert.deepEqual(h.started, [3]); assert.deepEqual(h.changed, [true]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("cancels the login PTY and rejects further terminal input", () => @@ -329,7 +331,7 @@ it.effect("cancels the login PTY and rejects further terminal input", () => }).pipe(Effect.result))._tag, "Failure", ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("does not report authentication when post-login verification fails", () => @@ -343,5 +345,5 @@ it.effect("does not report authentication when post-login verification fails", ( yield* h.phase("failed"); assert.deepEqual(h.changed, []); assert.isTrue(h.killed()); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/packages/provider-acp-registry/src/server/auth.ts b/packages/provider-acp-registry/src/server/auth.ts index 29da27bb291ba..240bca67cea57 100644 --- a/packages/provider-acp-registry/src/server/auth.ts +++ b/packages/provider-acp-registry/src/server/auth.ts @@ -4,7 +4,7 @@ import { type ProviderInstanceId, } from "@t3tools/contracts"; import type { AcpRegistrySettings } from "../settings.ts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -39,7 +39,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o }) { const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter); const coordinator = yield* Effect.serviceOption( AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, @@ -75,7 +75,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o Layer.provide( Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(SecureRandom.SecureRandom, random), ), ), ), diff --git a/packages/provider-acp-registry/src/server/authenticationState.test.ts b/packages/provider-acp-registry/src/server/authenticationState.test.ts index 09229cc300e4d..5111c6b349682 100644 --- a/packages/provider-acp-registry/src/server/authenticationState.test.ts +++ b/packages/provider-acp-registry/src/server/authenticationState.test.ts @@ -1,4 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, it } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; import { AcpRegistrySettings } from "../settings.ts"; @@ -10,7 +12,7 @@ import { makeAcpRegistryAuthenticationState } from "./authenticationState.ts"; const settings = Schema.decodeSync(AcpRegistrySettings)({ agentId: "devin" }); -it.layer(NodeServices.layer)("ACP sign-in confirmation", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ACP sign-in confirmation", (it) => { it.effect("restores explicit sign-in across driver recreation and server restart", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/provider-acp-registry/src/server/authenticationState.ts b/packages/provider-acp-registry/src/server/authenticationState.ts index 0d076781edf61..44502ceae4670 100644 --- a/packages/provider-acp-registry/src/server/authenticationState.ts +++ b/packages/provider-acp-registry/src/server/authenticationState.ts @@ -1,6 +1,6 @@ import type { ProviderInstanceEnvironment, ProviderInstanceId } from "@t3tools/contracts"; import type { AcpRegistrySettings } from "../settings.ts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -16,9 +16,11 @@ const decodeState = Schema.decodeUnknownEffect( ); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const hash = Effect.fn("AcpRegistryAuthenticationState.hash")(function* (value: unknown) { - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const json = yield* encodeJson(value).pipe(Effect.orDie); - const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(json)).pipe(Effect.orDie); + const digest = yield* crypto + .digest(Digest.sha256, new TextEncoder().encode(json)) + .pipe(Effect.orDie); return Hex.encode(digest); }); diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index ff362cda03473..2c77170379721 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -11,7 +11,8 @@ import { AcpRegistrySettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -466,7 +467,10 @@ export const checkAcpRegistryProviderReadiness = Effect.fn( : snapshot; }); -export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost.ProviderHost; +export type AcpRegistryDriverEnv = + | AcpRegistryAdapterV2DriverEnv + | Digest.Digest + | ProviderHost.ProviderHost; export const AcpRegistryDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -498,7 +502,7 @@ export const AcpRegistryDriver: ProviderDriver, ): Effect.Effect => { const provided = effect.pipe( Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), ); return Option.isSome(runtimeCoordinator) ? provided.pipe( @@ -795,7 +799,7 @@ export const AcpRegistryDriver: ProviderDriver confirmedAuthentication.set(false)), Effect.tap(() => liveSnapshotSemaphore.withPermit( diff --git a/packages/provider-acp-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index 0ceabbcfd81a7..58b16e337079e 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -11,7 +11,7 @@ import { type ServerProviderSlashCommand, } from "@t3tools/contracts"; import { AcpRegistrySettings } from "../settings.ts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -320,11 +320,13 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo ): Effect.fn.Return< AcpRegistryConfigurationProbeResult, AcpRegistryOperationError, - AcpRegistrySupport.AcpRegistryCatalog | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | AcpRegistrySupport.AcpRegistryCatalog + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom > { const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter); const result = yield* Effect.gen(function* () { @@ -362,7 +364,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo Layer.provide( Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(SecureRandom.SecureRandom, random), ), ), ), @@ -463,7 +465,7 @@ const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagem function* (input: AcpRegistryManagementInput) { const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const runtimeCoordinator = yield* Effect.serviceOption( AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, ); @@ -487,7 +489,7 @@ const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagem Layer.provide( Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(SecureRandom.SecureRandom, random), ), ), ), diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 6756cea5fab0c..cd6b1fb45b612 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -6,7 +6,7 @@ import * as NodePath from "node:path"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -21,6 +21,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpClient from "effect-acp/client"; @@ -123,7 +124,12 @@ export interface AcpSessionRuntimeOptions { /** Authenticate before opening: Antigravity selects its credential source from the method. */ readonly authenticateEagerly?: boolean; readonly mcpServers?: ReadonlyArray; + /** Sent on every session/new, load, resume and fork; omitting them clears an agent's stored roots. */ readonly additionalDirectories?: ReadonlyArray; + /** Session `_meta`, chosen from the negotiated agent before each session setup or activation. */ + readonly sessionMeta?: ( + initializeResult: EffectAcpSchema.InitializeResponse, + ) => EffectAcpSchema.NewSessionRequest["_meta"] | undefined; readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"]; readonly transformSessionUpdate?: ( notification: EffectAcpSchema.SessionNotification, @@ -1121,6 +1127,8 @@ export class AcpSessionRuntime extends Context.Service< readonly drainEvents: Effect.Effect; readonly getModeState: Effect.Effect; readonly getConfigOptions: Effect.Effect>; + /** The current configuration options, then each later change. */ + readonly configOptionChanges: Stream.Stream>; readonly loadSession: ( sessionId: string, options?: AcpSessionActivationOptions, @@ -1132,6 +1140,7 @@ export class AcpSessionRuntime extends Context.Service< readonly forkSession: ( sessionId: string, options?: AcpSessionActivationOptions, + meta?: NonNullable, ) => Effect.Effect; readonly listSessions: ( cursor?: string, @@ -1231,22 +1240,16 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( ): Effect.fn.Return< AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope > { - const sessionWorkspace = { - cwd: options.cwd, - ...(options.additionalDirectories?.length - ? { additionalDirectories: options.additionalDirectories } - : {}), - }; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.fork(yield* Scope.Scope); const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); const shownToolCallIds = new Set(); - const assistantItemRuntimeId = yield* crypto.randomUUIDv4().pipe( + const assistantItemRuntimeId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new EffectAcpErrors.AcpTransportError({ @@ -1256,7 +1259,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( ), ); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); - const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + const configOptionsRef = yield* SubscriptionRef.make(sessionConfigOptionsFromSetup(undefined)); const initializeStateRef = yield* Ref.make({ _tag: "NotStarted" }); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const startupMetadataRef = yield* Ref.make>( @@ -1683,17 +1686,26 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + // Counts applied `config_option_update`s; see setConfigOption. + const configOptionUpdates = yield* Ref.make(0); const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => - handleSessionUpdate({ - queue: eventQueue, - modeStateRef, - configOptionsRef, - toolCallsRef, - shownToolCallIds, - assistantSegmentRef, - assistantItemRuntimeId, - params: notification, - }); + (notification.update.sessionUpdate === "config_option_update" + ? Ref.update(configOptionUpdates, (count) => count + 1) + : Effect.void + ).pipe( + Effect.andThen( + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + configOptionsRef, + toolCallsRef, + shownToolCallIds, + assistantSegmentRef, + assistantItemRuntimeId, + params: notification, + }), + ), + ); yield* acp.handleSessionUpdate((notification) => notificationSemaphore.withPermit( @@ -1808,7 +1820,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( value: string | boolean, allowUnlistedValue: boolean, ): Effect.fn.Return { - const configOptions = yield* Ref.get(configOptionsRef); + const configOptions = yield* SubscriptionRef.get(configOptionsRef); const configOption = findSessionConfigOption(configOptions, configId); if (!configOption) { return; @@ -1869,7 +1881,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( | EffectAcpSchema.ResumeSessionResponse, ) { const configOptions = sessionConfigOptionsFromSetup(response); - yield* Ref.set(configOptionsRef, configOptions); + yield* SubscriptionRef.set(configOptionsRef, configOptions); yield* Queue.offer(eventQueue, { _tag: "ConfigOptionsUpdated", configOptions, @@ -1912,9 +1924,15 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( sessionSetupResult.configOptions !== undefined && sessionSetupResult.configOptions !== null ) { - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* SubscriptionRef.set( + configOptionsRef, + sessionConfigOptionsFromSetup(sessionSetupResult), + ); } else if (!syntheticReplayIdle) { - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* SubscriptionRef.set( + configOptionsRef, + sessionConfigOptionsFromSetup(sessionSetupResult), + ); } const nextState = { sessionId, @@ -2017,7 +2035,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( validateConfigOptionValue(configId, value, options?.allowUnlistedValue === true).pipe( Effect.andThen(getStartedState), Effect.flatMap((started) => - Ref.get(configOptionsRef).pipe( + SubscriptionRef.get(configOptionsRef).pipe( Effect.flatMap((configOptions) => { const existing = findSessionConfigOption(configOptions, configId); if (existing && configOptionCurrentValueMatches(existing, value)) { @@ -2038,11 +2056,27 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( configId, value: String(value), } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); - return runLoggedRequest( - "session/set_config_option", - requestPayload, - acp.agent.setSessionConfigOption(requestPayload), - ).pipe(Effect.tap((response) => updateConfigOptions(response))); + // An update the agent pushed while the write was in flight is + // newer than its result: Kiro 2.32 answers a `model` write + // without `model`, then advertises it, and that update can be + // handled before this fiber resumes. The result then stays out. + return Ref.get(configOptionUpdates).pipe( + Effect.flatMap((updatesBefore) => + runLoggedRequest( + "session/set_config_option", + requestPayload, + acp.agent.setSessionConfigOption(requestPayload), + ).pipe( + Effect.tap((response) => + Ref.get(configOptionUpdates).pipe( + Effect.flatMap((updatesNow) => + updatesNow === updatesBefore ? updateConfigOptions(response) : Effect.void, + ), + ), + ), + ), + ), + ); }), ), ), @@ -2114,6 +2148,16 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( return activationOptions?.mcpServers ?? options.mcpServers ?? []; }; + const sessionSetupFields = (initializeResult: EffectAcpSchema.InitializeResponse) => { + const meta = options.sessionMeta?.(initializeResult); + return { + ...(options.additionalDirectories?.length + ? { additionalDirectories: options.additionalDirectories } + : {}), + ...(meta == null ? {} : { _meta: meta }), + }; + }; + const startOnce = Effect.gen(function* () { const initializeResult = yield* initialize; @@ -2169,6 +2213,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( | EffectAcpSchema.LoadSessionResponse | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; + const sessionWorkspace = { cwd: options.cwd, ...sessionSetupFields(initializeResult) }; if (options.resumeSessionId) { sessionId = options.resumeSessionId; if ( @@ -2236,7 +2281,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( ); yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* SubscriptionRef.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); const nextState = { sessionId, @@ -2393,13 +2438,15 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( getEvents: () => Stream.fromQueue(eventQueue), drainEvents, getModeState: Ref.get(modeStateRef), - getConfigOptions: Ref.get(configOptionsRef), + getConfigOptions: SubscriptionRef.get(configOptionsRef), + configOptionChanges: SubscriptionRef.changes(configOptionsRef), loadSession: (sessionId, activationOptions) => start.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId, - ...sessionWorkspace, + cwd: options.cwd, + ...sessionSetupFields(started.initializeResult), mcpServers: sessionMcpServers(started.initializeResult, activationOptions), } satisfies EffectAcpSchema.LoadSessionRequest; return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); @@ -2411,7 +2458,8 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( Effect.flatMap((started) => { const requestPayload = { sessionId, - ...sessionWorkspace, + cwd: options.cwd, + ...sessionSetupFields(started.initializeResult), mcpServers: sessionMcpServers(started.initializeResult, activationOptions), } satisfies EffectAcpSchema.ResumeSessionRequest; return runLoggedRequest( @@ -2422,13 +2470,15 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( }), Effect.flatMap((response) => adoptSession(sessionId, response)), ), - forkSession: (sessionId, activationOptions) => + forkSession: (sessionId, activationOptions, meta) => start.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId, - ...sessionWorkspace, + cwd: options.cwd, + ...sessionSetupFields(started.initializeResult), mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + ...(meta === undefined ? {} : { _meta: meta }), } satisfies EffectAcpSchema.ForkSessionRequest; return runLoggedRequest( "session/fork", @@ -2623,7 +2673,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( if (modeState?.currentModeId === modeId) { return {} satisfies EffectAcpSchema.SetSessionModeResponse; } - const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( + const modeConfigOption = (yield* SubscriptionRef.get(configOptionsRef))?.find( (option) => option.category === "mode" && option.type === "select", ); if (modeConfigOption === undefined && modeState !== undefined) { @@ -2674,7 +2724,7 @@ export const layer = ( ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom > => Layer.effect(AcpSessionRuntime, make(options)); function sessionConfigOptionsFromSetup( @@ -2726,7 +2776,9 @@ const handleSessionUpdate = }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; - readonly configOptionsRef: Ref.Ref>; + readonly configOptionsRef: SubscriptionRef.SubscriptionRef< + ReadonlyArray + >; readonly toolCallsRef: Ref.Ref>; readonly shownToolCallIds: Set; readonly assistantSegmentRef: Ref.Ref; @@ -2735,7 +2787,7 @@ const handleSessionUpdate = }): Effect.fn.Return { if (params.update.sessionUpdate === "config_option_update") { const configOptions = params.update.configOptions; - yield* Ref.set(configOptionsRef, configOptions); + yield* SubscriptionRef.set(configOptionsRef, configOptions); yield* Ref.update( modeStateRef, (current) => parseSessionModeState({ configOptions }) ?? current, diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 731534ac7f970..cdae441afbafb 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -43,10 +43,11 @@ import { resolveT3McpToolId } from "@t3tools/shared/t3McpToolPresentation"; import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; +import * as Clock from "effect/Clock"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import type * as Duration from "effect/Duration"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; @@ -236,28 +237,35 @@ export function acpPerTurnPromptUsage( usage: EffectAcpSchema.Usage | null | undefined, hasSubagents: boolean, terminalStatus: OrchestrationV2ProviderTurn["status"], + geminiUsage = false, ): TurnTokenUsage { if (usage == null) { return { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents }; } + const inputTokens = geminiUsage + ? usage.inputTokens + (usage.cachedReadTokens ?? 0) + : usage.inputTokens; + const outputTokens = geminiUsage + ? usage.outputTokens + (usage.thoughtTokens ?? 0) + : usage.outputTokens; // TurnTokenUsage contract: input includes cache read + write and reasoning // is a subset of output, so clamp counters that exceed their parent and // drop the ones the agent left unset. Cache writes are capped at whatever // cache reads leave of input, so the pair cannot exceed it. const clampSubset = (value: number | null | undefined, parent: number) => value == null ? undefined : Math.min(value, parent); - const cachedInputTokens = clampSubset(usage.cachedReadTokens, usage.inputTokens); + const cachedInputTokens = clampSubset(usage.cachedReadTokens, inputTokens); const cacheCreationTokens = clampSubset( usage.cachedWriteTokens, - usage.inputTokens - (cachedInputTokens ?? 0), + inputTokens - (cachedInputTokens ?? 0), ); - const reasoningTokens = clampSubset(usage.thoughtTokens, usage.outputTokens); + const reasoningTokens = clampSubset(usage.thoughtTokens, outputTokens); return { usageScope: "main_agent", usageStatus: terminalStatus === "completed" ? "complete" : "partial", hasSubagents, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, + inputTokens, + outputTokens, ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), ...(cacheCreationTokens === undefined ? {} : { cacheCreationTokens }), ...(reasoningTokens === undefined ? {} : { reasoningTokens }), @@ -268,9 +276,25 @@ export interface AcpAdapterV2Flavor { readonly buildPrompt?: (input: { readonly text: string; readonly attachments: ReadonlyArray; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"]; + readonly agentVersion: string | undefined; + /** The session's model after selection, with its display name when the agent lists it. */ + readonly currentModel: { readonly id: string; readonly name: string | undefined } | undefined; }) => Effect.Effect, EffectAcpErrors.AcpError>; readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; + /** + * Runs before `session/prompt`. The returned check sees a prompt the agent ended + * without an error or cancel, and reports a failure its stop reason hid. A reported + * failure restarts the runtime before the next turn. + */ + readonly watchPromptOutcome?: ( + sessionId: string, + ) => Effect.Effect< + ( + result: EffectAcpSchema.PromptResponse, + ) => Effect.Effect + >; readonly requiresSignIn?: (cause: unknown) => boolean; /** * Runs before attachment paths, T3 instructions and native slash-command detection, @@ -289,6 +313,12 @@ export interface AcpAdapterV2Flavor { readonly normalizeSessionUpdate?: ( notification: EffectAcpSchema.SessionNotification, ) => EffectAcpSchema.SessionNotification; + /** + * Agent updates that belong to no turn (Kiro's own cloud-config fetch). They + * are dropped before anything reads them, so they neither show in the turn + * nor count as it starting. + */ + readonly ignoreSessionUpdate?: (notification: EffectAcpSchema.SessionNotification) => boolean; readonly onAvailableCommandsUpdate?: ( commands: ReadonlyArray, ) => Effect.Effect; @@ -316,10 +346,16 @@ export interface AcpAdapterV2Flavor { ) => Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope >; readonly customModels?: ReadonlyArray; readonly resolveModelId?: (selection: ModelSelection) => string | undefined; + /** + * The agent advertises its model option only after `session/new` returns + * (Kiro sends it in a `config_option_update`), so in-session model switching + * is not inferred from the setup result. + */ + readonly modelOptionArrivesLate?: boolean; readonly getModelContextWindow?: ( selection: ModelSelection, session: AcpSessionRuntime.AcpSessionRuntimeStartResult, @@ -329,9 +365,23 @@ export interface AcpAdapterV2Flavor { readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult; readonly modelSelection: ModelSelection; }) => Effect.Effect; + /** + * Model-selection option ids `applyModelSelection` sets itself under a + * native id, which the generic option loop then skips (Kiro's + * `reasoningEffort` is its `effortLevel`). + */ + readonly ownedModelOptionIds?: ReadonlyArray; readonly sessionModeForPolicy?: ( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) => string | undefined; + /** + * Native select-option values to apply for a runtime policy (e.g. Kiro's + * `autopilot`), set on every session configure. A value the session does + * not advertise is skipped; an agent rejecting it fails the session. + */ + readonly sessionConfigForPolicy?: ( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + ) => ReadonlyArray<{ readonly id: string; readonly value: string }>; /** * The runtime mode a native session mode grants, for flavors whose modes are permission levels. * Under a policy ceiling, a mode the user picks that this does not place under it is not applied. @@ -368,6 +418,41 @@ export interface AcpAdapterV2Flavor { ) => Effect.Effect; readonly subagentsIdleOnTurnCompletion?: boolean; readonly supportsCompaction?: boolean; + /** + * Native steering: `send` queues a message into the running turn and returns the + * agent's id for it, which `injectedSteerId` reads from the update that delivers it. + */ + readonly steering?: { + readonly send: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + text: string, + ) => Effect.Effect; + readonly injectedSteerId: ( + notification: EffectAcpSchema.SessionNotification, + ) => string | undefined; + }; + /** + * Native fork from an earlier turn. `nativeTurnId` reads the agent's id for a running + * turn's prompt, which the turn keeps as its native ref; forking from that turn sends + * `forkMeta(id)` as the `session/fork` `_meta`. + */ + readonly forkFromTurn?: { + readonly supported: ( + initializeResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["initializeResult"], + ) => boolean; + readonly nativeTurnId: ( + notification: EffectAcpSchema.SessionNotification, + ) => string | undefined; + readonly forkMeta: ( + nativeTurnId: string, + ) => NonNullable; + }; + /** Compacts with the agent's own request, for agents that answer a `/compact` prompt as chat. */ + readonly compactSession?: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + ) => Effect.Effect; readonly runtimeHarness?: string; readonly registerExtensions?: ( context: AcpAdapterV2ExtensionContext, @@ -408,11 +493,22 @@ export interface AcpAdapterV2Flavor { readonly assertComplete?: Effect.Effect; /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ readonly interruptPromptOnCancel?: boolean; + /** + * Hold `session/cancel` until an update after `session/prompt` matches `isStart`, + * or `bound` has passed; skip it if the prompt settles first. Kiro 2.27.1 can + * drop a cancel sent just after the prompt and run the turn to completion. + */ + readonly cancelAfterPromptStarts?: { + readonly isStart: (update: EffectAcpSchema.SessionUpdate) => boolean; + readonly bound: Duration.Input; + }; /** * The agent's PromptResponse.usage counters read per-turn. The ACP spec * leaves usage semantics open, so this stays unset until proven live. */ readonly reportsPerTurnPromptUsage?: boolean; + /** Gemini-style usage: inputTokens leaves out cachedReadTokens, outputTokens leaves out thoughtTokens. */ + readonly geminiPromptUsage?: boolean; /** Grok can retain task_already_running until process exit. */ readonly restartRuntimeAfterInterrupt?: boolean; /** Prompt failures after which the agent process keeps failing until replaced. */ @@ -429,6 +525,13 @@ export interface AcpAdapterV2Flavor { * fail before `session/prompt`. */ readonly supportsImagePrompts?: boolean; + /** + * True when this session's native system prompt carries T3's PR-linking instructions, so + * turns omit them. Must use the same negotiated check that sent them on session setup. + */ + readonly sessionCarriesPullRequestLinking?: ( + initializeResult: EffectAcpSchema.InitializeResponse, + ) => boolean; } function acpSupportsImagePrompts(input: { @@ -502,6 +605,8 @@ export interface AcpAdapterV2Options { }; readonly testHooks?: { readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect; + /** `cancelAfterPromptStarts`: Stop is holding `session/cancel` until the prompt starts. */ + readonly onCancelHeld?: () => Effect.Effect; readonly afterPromptWireSettled?: () => Effect.Effect; readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect; readonly onNativeResponseLifecycle?: (event: { @@ -616,15 +721,57 @@ export const AcpProviderCapabilitiesV2 = { }, } satisfies OrchestrationV2ProviderCapabilities; +const STEER_DELIVERY_BOUND = "2 seconds"; + +/** + * `session/fork` `_meta` for a fork from `providerTurnId`: none for the session head, + * the flavor's meta for a turn the agent named, or `null` when the agent can't fork there. + */ +function forkFromTurnMetaFor(input: { + readonly driver: ProviderDriverKind; + readonly flavor: Pick; + readonly canForkFromTurn: boolean; + readonly forkInput: ProviderAdapter.ProviderAdapterV2ForkThreadInput; +}): NonNullable | undefined | null { + const { forkInput } = input; + if (forkInput.providerTurnId === undefined) return undefined; + const turns = (forkInput.sourceProviderTurns ?? []).filter( + (turn) => turn.providerThreadId === forkInput.sourceProviderThread.id, + ); + const target = turns.find((turn) => turn.id === forkInput.providerTurnId); + if (target === undefined) return null; + if (turns.every((turn) => turn.ordinal <= target.ordinal)) return undefined; + const ref = target.nativeTurnRef; + if ( + input.flavor.forkFromTurn === undefined || + !input.canForkFromTurn || + ref === null || + ref.driver !== input.driver || + ref.strength !== "strong" || + ref.nativeId === null + ) { + return null; + } + return input.flavor.forkFromTurn.forkMeta(ref.nativeId); +} + +function isCompactionMessage(message: ProviderAdapter.ProviderAdapterV2TurnMessage): boolean { + return message.text.trim() === "/compact" && message.attachments.length === 0; +} + function negotiatedCapabilities( base: OrchestrationV2ProviderCapabilities, started: AcpSessionRuntime.AcpSessionRuntimeStartResult, + modelOptionArrivesLate: boolean, + forksFromTurn: boolean, ): OrchestrationV2ProviderCapabilities { const agent = started.initializeResult.agentCapabilities ?? {}; const session = agent.sessionCapabilities; const setup = started.sessionSetupResult; const canSwitchModel = - findModelConfigOption(setup.configOptions) !== undefined || setup.models != null; + modelOptionArrivesLate || + findModelConfigOption(setup.configOptions) !== undefined || + setup.models != null; const canLoad = agent.loadSession === true; const canFork = session?.fork != null; return { @@ -637,7 +784,7 @@ function negotiatedCapabilities( ...base.threads, canReadThreadSnapshot: canLoad, canForkThread: canFork, - canForkFromTurn: false, + canForkFromTurn: canFork && forksFromTurn, }, tools: { ...base.tools, @@ -914,12 +1061,45 @@ function structuredFileChanges(toolCall: AcpToolCallState) { // Bound diff search to protect the event loop; empty-sided diffs need no search. const ACP_V1_DIFF_MAX_EDITS = 1_000; -export function acpToolCallDiffPatch(content: unknown): string | undefined { +/** + * A `snippet` patch has no known location. Keep its recorded text, so no view numbers it + * from line 1. + */ +export function acpToolCallSnippetEdit(content: unknown) { if (!Array.isArray(content)) return undefined; const diffs = content.flatMap((entry) => { const diff = unknownRecord(entry); return diff?.type === "diff" ? [diff] : []; }); + const diff = diffs.length === 1 ? diffs[0] : undefined; + if (diff === undefined || unknownRecord(diff.patch)?.format !== "snippet") return undefined; + return typeof diff.oldText === "string" && typeof diff.newText === "string" + ? { oldStr: diff.oldText, newStr: diff.newText } + : undefined; +} + +/** + * No preview when a flavor marked a diff `omitted` (too large) or `snippet` (line unknown); + * the checkpoint diff shows it. + */ +function acpToolCallDiffUnpreviewable(content: unknown): boolean { + return ( + Array.isArray(content) && + content.some((entry) => { + const format = unknownRecord(unknownRecord(entry)?.patch)?.format; + return format === "omitted" || format === "snippet"; + }) + ); +} + +export function acpToolCallDiffPatch(content: unknown): string | undefined { + if (!Array.isArray(content) || acpToolCallDiffUnpreviewable(content)) { + return undefined; + } + const diffs = content.flatMap((entry) => { + const diff = unknownRecord(entry); + return diff?.type === "diff" ? [diff] : []; + }); for (const diff of diffs) { const patch = unknownRecord(diff.patch); if (typeof patch?.text === "string") { @@ -1051,6 +1231,18 @@ function toolStatus( type ProjectedToolStatus = ReturnType | "interrupted"; +/** + * Some agents answer Stop's `session/cancel` by failing the tools and subagents + * that were running (Kiro: "Tool execution was cancelled by the user."). After + * Stop, a failure is shown as the interruption it was. + */ +function stoppedToolStatus( + turn: Pick, + tool: AcpToolCallState, +): "interrupted" | undefined { + return turn.interrupted && tool.status === "failed" ? "interrupted" : undefined; +} + function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] { return status === "pending" ? "running" : status; } @@ -1181,8 +1373,16 @@ interface ActiveAcpTurn { promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; workAccepted: boolean; readonly promptWireSettled: Deferred.Deferred; + /** Completed once `session/prompt` is the runtime's active prompt. */ + readonly promptDispatched: Deferred.Deferred; + /** Clock millis just before `session/prompt` was handed to the runtime. */ + promptSentAt: number | null; + /** Completed when an update after dispatch matches `cancelAfterPromptStarts.isStart`. */ + readonly promptStarted: Deferred.Deferred; promptUsage: EffectAcpSchema.Usage | undefined; backgroundFinalizeGeneration: number; + /** The agent's own id for this turn's prompt, from `forkFromTurn.nativeTurnId`. */ + nativePromptId: string | undefined; } type AcpRuntimeTeardownState = @@ -1446,7 +1646,7 @@ function shouldPersistToolUpdate( export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( options: AcpAdapterV2Options, ) { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const agentScope = yield* AgentScope; @@ -1653,6 +1853,15 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( input.runtimePolicy; const events = yield* Queue.unbounded(); const activeTurn = yield* Ref.make(null); + // Keyed by the agent's steer id; an injection can arrive before its send returns. + const steerInjections = new Map>(); + const steerInjection = (steerId: string) => { + const existing = steerInjections.get(steerId); + if (existing !== undefined) return existing; + const created = Deferred.makeUnsafe(); + steerInjections.set(steerId, created); + return created; + }; let pendingStopCloseTurn: ActiveAcpTurn | null = null; let followsAgentWorkState = false; let agentWork: Extract< @@ -2168,8 +2377,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? {} : { requestLogger: nativeLogging.requestLogger }), protocolLogging: nativeLogging?.protocolLogging ?? { - logIncoming: true, - logOutgoing: true, + logIncoming: false, + logOutgoing: false, logger: () => Effect.void, }, }; @@ -2803,9 +3012,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const emitSubagent = Effect.fnUntraced(function* ( context: ActiveAcpTurn, - update: AcpAdapterV2SubagentUpdate, + reported: AcpAdapterV2SubagentUpdate, ) { - // Hydration uses a new toolCallId but references the original child by task id. + // See stoppedToolStatus: after Stop, a failed subagent was interrupted. + const update: AcpAdapterV2SubagentUpdate = + context.interrupted && reported.status === "failed" + ? { ...reported, status: "interrupted" } + : reported; + // Hydration uses a new toolCallId but references the original child by subagent_id or task id. const existing = context.subagents.get(update.nativeTaskId) ?? (update.childSessionId !== null @@ -3301,10 +3515,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { for (const event of parseSessionUpdateEvent(notification).events) { if (event._tag === "ToolCallUpdated") { + const tool = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; yield* projectSubagentTool( subagent, notification.sessionId, - flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall, + tool, + stoppedToolStatus(owner.owner, tool), ); } } @@ -3670,7 +3886,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* rearmDeferredFinalize(context); return; } - const status = projectedStatus ?? toolStatus(toolCall.status); + const status = + projectedStatus ?? stoppedToolStatus(context, toolCall) ?? toolStatus(toolCall.status); const now = yield* DateTime.now; const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; const ordinal = yield* resolveItemOrdinal(context, nativeItemId); @@ -3926,14 +4143,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } case "edit": case "delete": - case "move": + case "move": { + const snippet = acpToolCallSnippetEdit(toolCall.data.content); + const unpreviewable = acpToolCallDiffUnpreviewable(toolCall.data.content); turnItem = { ...base, type: "file_change", fileName: path ?? toolCall.title ?? "File change", - ...(diffText === undefined ? {} : { diffStr: diffText }), + ...(snippet ?? + (unpreviewable || diffText === undefined ? {} : { diffStr: diffText })), }; break; + } case "fetch": { // Grok rawOutput.Content is unreadable by textFromUnknown; bounded content blocks duplicate it. const snippet = @@ -4953,7 +5174,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } continue; } - yield* projectSubagentTool(subagent, notification.sessionId, toolCall); + yield* projectSubagentTool( + subagent, + notification.sessionId, + toolCall, + stoppedToolStatus(context, toolCall), + ); } return; } @@ -5918,10 +6144,37 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); + // Only the flavor's own start marker counts: agents also send late + // updates for the previous prompt (Kiro's context usage) and echoes + // that arrive before they would honor a cancel. + const markPromptStarted = Effect.fnUntraced(function* ( + notification: EffectAcpSchema.SessionNotification, + ) { + const promptStarts = flavor.cancelAfterPromptStarts?.isStart; + if (promptStarts === undefined) return; + const context = yield* Ref.get(activeTurn); + if (context === null || context.nativeThreadId !== notification.sessionId) return; + if (!(yield* Deferred.isDone(context.promptDispatched))) return; + if (!promptStarts(notification.update)) return; + yield* Deferred.succeed(context.promptStarted, undefined); + }); const projectAcpRuntimeSessionUpdateEffect = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced( function* (rawNotification: EffectAcpSchema.SessionNotification) { + if (flavor.ignoreSessionUpdate?.(rawNotification) === true) return; + const nativePromptId = flavor.forkFromTurn?.nativeTurnId(rawNotification); + if (nativePromptId !== undefined) { + const context = yield* Ref.get(activeTurn); + if (context !== null && context.nativeThreadId === rawNotification.sessionId) { + context.nativePromptId ??= nativePromptId; + } + } + const injectedSteerId = flavor.steering?.injectedSteerId(rawNotification); + if (injectedSteerId !== undefined) { + yield* Deferred.succeed(steerInjection(injectedSteerId), undefined); + } + yield* markPromptStarted(rawNotification); const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); if (embedded !== undefined) { rememberEmbeddedTerminals({ @@ -6138,8 +6391,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* userInput.acknowledgeNativeResponse; return response ?? ({ outcome: { outcome: "cancelled" } } as const); } - const dispositionFor = (policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy) => - (flavor.permissionDisposition ?? acpPermissionDisposition)(policy, params); + const dispositionFor = ( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + request: EffectAcpSchema.RequestPermissionRequest = params, + ) => (flavor.permissionDisposition ?? acpPermissionDisposition)(policy, request); if (followsAgentWorkState && dispositionFor(latestRuntimePolicy) === "ask") { if (!(yield* awaitRequestTurn(handlerGeneration))) return { outcome: { outcome: "cancelled" } } as const; @@ -6150,8 +6405,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const context = followsAgentWorkState ? yield* Ref.get(activeTurn) : yield* activeContext; + // Kiro sends the tool's kind on its `tool_call` and leaves it + // off the permission request for that call; take it from the + // tool already seen under the same id so policy can tell a + // read from a write. + const knownKind = context?.tools.get(params.toolCall.toolCallId)?.kind; const disposition = dispositionFor( context?.input.runtimePolicy ?? latestRuntimePolicy, + params.toolCall.kind == null && knownKind !== undefined + ? { ...params, toolCall: { ...params.toolCall, kind: knownKind } } + : params, ); if (disposition === "allow") { const optionId = selectAutoApprovedPermissionOption(params); @@ -6213,7 +6476,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( transportRequestId: pendingTransportRequestId, } = admitted.value.pending; const parsedPermission = parsePermissionRequest(params); - const decision = yield* Deferred.await(pendingDecision).pipe( + const answered = yield* Deferred.await(pendingDecision).pipe( Effect.ensuring( runRuntimeCallbackAtGeneration( handlerGeneration, @@ -6225,6 +6488,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ).pipe(Effect.asVoid), ), ); + // A card that never offered "this session" (Kiro's, whose + // allow_always saves a workspace rule) cannot be answered with + // it by a stale client or a hand-made respond call. + const offeredOptions = flavor.approvalOptions?.(params); + const decision = + answered === "acceptForSession" && + offeredOptions !== undefined && + !offeredOptions.some((option) => option.decision === "acceptForSession") + ? "accept" + : answered; if ( parsedPermission.kind !== "unknown" && (decision === "accept" || decision === "acceptForSession") @@ -6482,7 +6755,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const mcpBridge = yield* makeAcpMcpOverAcpBridge({ endpoint: mcpContext.endpoint, authorization: mcpContext.authorization, - allocateConnectionId: crypto.randomUUIDv4().pipe(Effect.orDie), + allocateConnectionId: random.uuidV4(), }); yield* Scope.addFinalizer(scope, mcpBridge.dispose); return mcpBridge; @@ -6503,7 +6776,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( .makeRuntime(makeRuntimeInput(runtimeGeneration, mcpContext, resumeSessionId)) .pipe( Effect.provideService(Scope.Scope, runtimeScope), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(AgentScope, agentScope), Effect.provideService(AgentScopeThreadId, threadId ?? undefined), @@ -6582,7 +6855,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) .pipe( Effect.provideService(Scope.Scope, replacementScope), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(AgentScope, agentScope), Effect.provideService(AgentScopeThreadId, threadId ?? undefined), @@ -6757,7 +7030,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* Ref.set(activeSessionId, started.sessionId); yield* setActiveSessionSetup(started); rememberTerminalEnvironment(started.sessionId, yield* readMcpContext(input.threadId)); - const capabilities = negotiatedCapabilities(flavor.capabilities, started); + const capabilities = negotiatedCapabilities( + flavor.capabilities, + started, + flavor.modelOptionArrivesLate === true, + flavor.forkFromTurn?.supported(started.initializeResult) === true, + ); const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; const canResumeSession = started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; @@ -6915,6 +7193,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return granted !== undefined && !isRuntimeModeAbove(granted, ceiling); }; const optionSelections = (modelSelection.options ?? []).flatMap((selection) => { + if (flavor.ownedModelOptionIds?.includes(selection.id) === true) return []; if (!withinCeiling(selection)) return []; return [ selection.value === ACP_EMPTY_OPTION_VALUE ? { ...selection, value: "" } : selection, @@ -6969,6 +7248,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }), ); } + for (const selection of flavor.sessionConfigForPolicy?.(runtimePolicy) ?? []) { + const option = (yield* runtime.getConfigOptions).find( + (candidate) => candidate.id === selection.id, + ); + if (option?.type !== "select" || option.currentValue === selection.value) continue; + const advertisedValues = option.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), + ); + if (!advertisedValues.includes(selection.value)) continue; + yield* runtime.setConfigOption(selection.id, selection.value); + } const policyMode = flavor.sessionModeForPolicy?.(runtimePolicy); if (policyMode !== undefined) { yield* runtime.setMode(policyMode); @@ -7084,11 +7374,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( providerThreadId: context.input.providerThread.id, nodeId: context.input.rootNodeId, runAttemptId: context.input.attemptId, - nativeTurnRef: { - driver, - nativeId: context.nativeTurnId, - strength: "weak", - }, + nativeTurnRef: + context.nativePromptId === undefined + ? { driver, nativeId: context.nativeTurnId, strength: "weak" } + : { driver, nativeId: context.nativePromptId, strength: "strong" }, ordinal: context.input.providerTurnOrdinal, status, startedAt: context.startedAt, @@ -7102,6 +7391,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context.promptUsage, context.subagents.size > 0, status, + flavor.geminiPromptUsage, ), }), }); @@ -7146,6 +7436,30 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); + /** + * `cancelAfterPromptStarts`: waits until the agent started the prompt or the + * bound passed since `session/prompt` (since Stop, if none was sent). False + * when the prompt settled first, so there is nothing to cancel. + */ + const promptStillRunsAfterHold = Effect.fnUntraced(function* (context: ActiveAcpTurn) { + const hold = flavor.cancelAfterPromptStarts; + if (hold === undefined) return true; + if (yield* Deferred.isDone(context.promptStarted)) return true; + yield* options.testHooks?.onCancelHeld?.() ?? Effect.void; + const boundPassed = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const elapsed = now - (context.promptSentAt ?? now); + yield* Effect.sleep( + Duration.millis(Math.max(0, Duration.toMillis(hold.bound) - elapsed)), + ); + }); + return yield* Effect.raceFirst( + Effect.raceFirst(Deferred.await(context.promptStarted), boundPassed).pipe( + Effect.as(true), + ), + Deferred.await(context.completed).pipe(Effect.as(false)), + ); + }); const quarantineStoppedRun = Effect.fnUntraced(function* () { yield* continuationPermit.withPermit( Effect.gen(function* () { @@ -7200,9 +7514,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } const now = yield* DateTime.now; if ( - flavor.supportsCompaction === true && - context.input.message.text.trim() === "/compact" && - context.input.message.attachments.length === 0 && + (flavor.supportsCompaction === true || flavor.compactSession !== undefined) && + isCompactionMessage(context.input.message) && settledStatus === "completed" ) { const nativeItemId = `${context.nativeTurnId}:compaction`; @@ -7320,6 +7633,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } yield* Ref.set(activeTurn, null); + steerInjections.clear(); if ( postSettleContinuationEnabled && settledStatus === "completed" && @@ -7419,11 +7733,24 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } if (flavor.buildPrompt) { const setup = yield* Ref.get(activeSessionSetup); + const modelOption = findModelConfigOption(yield* runtime.getConfigOptions); + const currentModelId = modelOption?.currentValue; const blocks = yield* flavor .buildPrompt({ text, attachments: turnInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, promptCapabilities: setup?.initializeResult.agentCapabilities?.promptCapabilities, + agentVersion: setup?.initializeResult.agentInfo?.version, + currentModel: + typeof currentModelId !== "string" || modelOption === undefined + ? undefined + : { + id: currentModelId, + name: flattenSelectChoices(modelOption.options).find( + (choice) => choice.value === currentModelId, + )?.name, + }, }) .pipe( Effect.mapError( @@ -7469,11 +7796,15 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); } if (!isNativeSlashCommand) { + const sessionSetup = yield* Ref.get(activeSessionSetup); prompt.push({ type: "text", text: buildRuntimeInstructions({ harness: flavor.runtimeHarness ?? driver, model: turnInput.modelSelection.model, + pullRequestLinking: + sessionSetup === null || + flavor.sessionCarriesPullRequestLinking?.(sessionSetup.initializeResult) !== true, }), }); } @@ -7597,6 +7928,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const providerTurnId = deriveProviderTurnId(nativeTurnId); const completed = yield* Deferred.make(); const promptWireSettled = yield* Deferred.make(); + const promptDispatched = yield* Deferred.make(); + const promptStarted = yield* Deferred.make(); const rememberedContextUsage = (yield* Ref.get(contextUsageBySessionId)).get( requestedSessionId, ); @@ -7652,8 +7985,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( promptSettledStatus: null, workAccepted: false, promptWireSettled, + promptDispatched, + promptSentAt: null, + promptStarted, promptUsage: undefined, backgroundFinalizeGeneration: 0, + nativePromptId: undefined, }; const carryover = yield* Ref.getAndSet(carryoverSubagents, null); let rehydratedCarryoverSubagents: ReadonlyArray = []; @@ -7788,11 +8125,29 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } return; } + const checkPromptOutcome = + flavor.watchPromptOutcome === undefined + ? undefined + : yield* flavor.watchPromptOutcome(requestedSessionId); const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); - yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe( + const promptOptions = { dispatched: context.promptDispatched }; + context.promptSentAt = yield* Clock.currentTimeMillis; + const compactSession = isCompactionMessage(turnInput.message) + ? flavor.compactSession + : undefined; + yield* ( + compactSession === undefined + ? runtime.prompt({ prompt: promptParts!.prompt }, promptOptions) + : Deferred.succeed(context.promptDispatched, undefined).pipe( + Effect.andThen(compactSession(runtime, requestedSessionId)), + Effect.as({ stopReason: "end_turn" } satisfies EffectAcpSchema.PromptResponse), + ) + ).pipe( Effect.tap(() => Ref.update(promptInstructionStates, (current) => { - if (promptParts?.instructionState === undefined) return current; + // Native compaction sends no prompt, so no instructions reached the agent. + if (promptParts?.instructionState === undefined || compactSession !== undefined) + return current; const updated = new Map(current); updated.set(requestedSessionId, promptParts.instructionState); return updated; @@ -7834,13 +8189,26 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (context.finalized || followsAgentWorkState) return; // ACP v2 reports a failed prompt as an idle `error`, not an RPC error. const idleError = result.stopReason === "error" && !context.interrupted; - const status = idleError - ? "failed" - : result.stopReason === "cancelled" - ? context.interrupted - ? "interrupted" - : "cancelled" - : "completed"; + const hiddenFailure = + checkPromptOutcome === undefined || + context.interrupted || + result.stopReason === "error" || + result.stopReason === "cancelled" + ? undefined + : yield* checkPromptOutcome(result); + if (hiddenFailure !== undefined) { + yield* Ref.set(runtimeRestartRequired, true); + } + const failure = + hiddenFailure ?? (idleError ? idleErrorFailure(result.error) : undefined); + const status = + failure !== undefined + ? "failed" + : result.stopReason === "cancelled" + ? context.interrupted + ? "interrupted" + : "cancelled" + : "completed"; if (flavor.deferFinalizeForBackgroundWork === true && !context.interrupted) { context.promptSettled = true; context.promptSettledStatus = status; @@ -7849,11 +8217,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* finishRegisteredBackgroundTool({ taskId, ...completion }); } } - if (idleError) { + if (failure !== undefined) { yield* terminalizeOpenForegroundTools(context, "failed"); // Unlike an RPC failure, the agent survives an idle error, so its // background tools and subagents keep reporting to this run. - yield* finalizeTurn(context, "failed", idleErrorFailure(result.error), { + yield* finalizeTurn(context, "failed", failure, { childTurnsContinue: hasSurvivingBackgroundWork(context), }); return; @@ -8175,7 +8543,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ), ), startTurn, - ...(flavor.supportsCompaction === true + ...(flavor.supportsCompaction === true || flavor.compactSession !== undefined ? { compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => startTurn({ @@ -8184,13 +8552,69 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }), } : {}), - steerTurn: (turnInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ - driver, - providerThreadId: turnInput.providerThread.id, - }), - ), + steerTurn: Effect.fn("AcpAdapterV2.steerTurn")( + function* (turnInput) { + const steering = flavor.steering; + // The agent takes text only, so a steer with attachments waits for the next turn. + if (steering === undefined || turnInput.message.attachments.length > 0) { + return yield* new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ + driver, + providerThreadId: turnInput.providerThread.id, + }); + } + const context = yield* Ref.get(activeTurn); + if ( + context === null || + context.finalized || + context.providerTurnId !== turnInput.providerTurnId + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Provider turn ${turnInput.providerTurnId} is not active and cannot be steered.`, + }); + } + // An agent that drops an early cancel drops an early steer too. + const startHold = flavor.cancelAfterPromptStarts; + if (startHold !== undefined) { + yield* Deferred.await(context.promptStarted).pipe( + Effect.timeoutOption(startHold.bound), + ); + } + const steerId = yield* steering.send( + runtime, + context.nativeThreadId, + turnInput.message.text, + ); + const injected = steerInjection(steerId); + // The agent reads a steer at its next step. One that lands after the last + // step is dropped, and the turn settles right after; the orchestrator then + // sends it as a follow-up. The bound keeps Stop from queueing behind a tool. + const outcome = yield* Effect.raceFirst( + Deferred.await(injected).pipe(Effect.as("injected" as const)), + Deferred.await(context.promptWireSettled).pipe(Effect.as("settled" as const)), + ).pipe(Effect.timeoutOption(STEER_DELIVERY_BOUND)); + steerInjections.delete(steerId); + if (Option.contains(outcome, "settled") && !(yield* Deferred.isDone(injected))) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Provider turn ${turnInput.providerTurnId} ended before it read the steering message.`, + }); + } + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSteerRunUnsupportedError" + ? cause + : new ProviderAdapter.ProviderAdapterSteerRunError({ + driver, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), + ), + ), + ), interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { return yield* Effect.uninterruptibleMask((restore) => @@ -8452,7 +8876,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); } else { // Settled soft interrupts have no prompt to cancel; cancel could kill background subagents. - if (!settledSoftInterrupt) { + if (!settledSoftInterrupt && (yield* promptStillRunsAfterHold(context))) { yield* runtime.cancel; } if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) { @@ -8706,7 +9130,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( detail: "ACP driver did not negotiate session/fork", }); } - if (forkInput.providerTurnId !== undefined) { + const forkMeta = forkFromTurnMetaFor({ + driver, + flavor, + canForkFromTurn: capabilities.threads.canForkFromTurn, + forkInput, + }); + if (forkMeta === null) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail: "ACP session/fork can only fork the current session head", @@ -8721,6 +9151,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const forked = yield* runtime.forkSession( sourceSessionId, acpMcpActivation(mcpContext), + forkMeta, ); rememberTerminalEnvironment( forked.sessionId, diff --git a/packages/provider-acp/src/server/nativeLogging.test.ts b/packages/provider-acp/src/server/nativeLogging.test.ts index a394a18154af6..b54945a718134 100644 --- a/packages/provider-acp/src/server/nativeLogging.test.ts +++ b/packages/provider-acp/src/server/nativeLogging.test.ts @@ -1,4 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -10,7 +12,7 @@ import * as AcpErrors from "effect-acp/errors"; import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeAcpNativeLoggerFactory } from "./nativeLogging.ts"; -const nodeServicesIt = it.layer(NodeServices.layer); +const nodeServicesIt = it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)); const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); nodeServicesIt("ACP native logging", (it) => { diff --git a/packages/provider-acp/src/server/nativeLogging.ts b/packages/provider-acp/src/server/nativeLogging.ts index eb11d9693c240..63b5936e5b071 100644 --- a/packages/provider-acp/src/server/nativeLogging.ts +++ b/packages/provider-acp/src/server/nativeLogging.ts @@ -1,7 +1,7 @@ import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -28,7 +28,7 @@ function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEv } export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; return (input: { readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; @@ -42,7 +42,7 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" { observedAt, event: { - id: yield* crypto.randomUUIDv4(), + id: yield* random.uuidV4(), kind: logInput.kind, provider: input.provider, createdAt: observedAt, diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index c35789ec47dc4..086628fcfae94 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -1366,6 +1366,20 @@ describe("extractMcpToolCallIdentity", () => { } }); + it("recovers T3 identity from Kiro's @-scoped titles", () => { + for (const tool of ["html_render", "t3_thread_read", "delegate_task"]) { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "kiro-1", + kind: "other", + title: `@t3-code/${tool}`, + status: "pending", + rawInput: { tool_id: `t3-code::${tool}`, arguments: { threadId: "thread-1" } }, + }); + expect(extractMcpToolCallIdentity(toolCall), tool).toEqual({ server: "t3-code", tool }); + } + }); + it("does not claim an agent's own tool that shares a T3 tool name", () => { const toolCall = toolCallFromUpdate({ sessionUpdate: "tool_call", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 49fd56cc19d6c..bc418eefcb228 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1067,9 +1067,9 @@ function acpMcpFallbackInput(value: string | undefined): Record return undefined; } -// Known T3 inventory gates matches, so server-name separators can stay loose. +// Known T3 inventory gates matches, so server-name separators can stay loose. Kiro titles "@t3-code/". const T3_MCP_TITLE_CALL = - /^(?:mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; + /^(?:@|mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; const T3_MCP_TITLE_SUFFIX_CALL = /^(?[A-Za-z0-9][A-Za-z0-9_.-]*?)(?: \(t3[-_ ]?code MCP Server\)(?::|$)|[-_.]t3[-_ ]?code$)/i; @@ -1095,6 +1095,18 @@ export function extractMcpToolCallIdentity( if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { return { server, tool }; } + // Kiro names the server in `_meta.kiro.serverName` and the call in `rawInput.tool_id` + // ("::"), with the tool's own input under `rawInput.arguments`. + const kiroServer = isRecord(meta?.kiro) ? meta.kiro.serverName : undefined; + const kiroToolId = typeof rawInput?.tool_id === "string" ? rawInput.tool_id.trim() : ""; + if ( + typeof kiroServer === "string" && + kiroServer.length > 0 && + kiroToolId.startsWith(`${kiroServer}::`) && + kiroToolId.length > kiroServer.length + 2 + ) { + return { server: kiroServer, tool: kiroToolId.slice(kiroServer.length + 2) }; + } const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; const gooseToolCall = isRecord(meta?.goose) ? isRecord(meta.goose.toolCall) diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 6f6dab555a233..25549d9dba015 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -96,6 +96,8 @@ export const ProviderAdapterV2Event = Schema.Union([ type: Schema.Literal("provider_thread.updated"), driver: ProviderDriverKind, providerThread: OrchestrationV2ProviderThread, + /** Work remaining at this point in the event stream, including native continuations. */ + pendingBackgroundWork: Schema.optional(Schema.Boolean), retainedNativeTurnIds: Schema.optional(Schema.Array(Schema.String)), }), Schema.Struct({ diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index 84cc14dc2e594..5ca689509c007 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -146,6 +146,16 @@ it("keeps unrecognized reasons and provider text out of category messages", () = { _tag: "ProviderAdapterProtocolError", reason: "private-config" }, { _tag: "ProviderAdapterProtocolError", reason: "constructor" }, { _tag: "constructor", cause: "private-config" }, + { + _tag: "CodexAppServerRequestError", + method: "thread/goal/set", + message: "thread not found: private-thread", + }, + { + _tag: "CodexAppServerRequestError", + method: "turn/start", + message: "goal objective must be at most 4000 characters", + }, ]) { assert.equal( makeProviderFailure({ cause: { _tag: "ProviderAdapterTurnStartError", cause } }).message, diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index efd08f8bfb84c..4ea74f1758de7 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -64,6 +64,10 @@ const PROVIDER_FAILURE_CATEGORIES: Partial< }, }; +// codex_protocol::protocol::validate_thread_goal_objective +const CODEX_GOAL_VALIDATION_MESSAGE = + /^goal objective must (?:not be empty|be at most \d{1,6} characters)$/u; + const PROVIDER_FAILURE_REASONS: Record = { previous_turn_active: "the previous turn is still running", attachment_unreadable: "an attachment could not be read", @@ -114,9 +118,24 @@ function curatedCauseMessages(cause: unknown): { case "ClaudeAppendSystemPromptMissingValueError": case "ClaudeSettingsLaunchArgError": case "ClaudeMcpRegistrationTimeoutError": + case "ClaudeCodeRootRefusalError": case "CursorModelUnavailableError": + case "CodexGoalObjectiveTooLongError": case "ProviderSetupError": return { known: stringField(cause, "message"), category: categoryMessage() }; + case "CodexAppServerRequestError": { + // Only Codex's fixed-shape goal validation text is actionable and + // safe to show; other request error text stays hidden. + const rejection = stringField(cause, "message"); + if ( + (cause as Record).method === "thread/goal/set" && + rejection !== undefined && + CODEX_GOAL_VALIDATION_MESSAGE.test(rejection) + ) { + return { known: `Codex rejected the goal: ${rejection}.`, category: categoryMessage() }; + } + break; + } case "AcpProcessExitedError": { const exit = `provider process exited${processExitStatus(cause)}`; const stage = category === undefined ? `The ${exit}` : `${category.summary}: the ${exit}`; diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts index b9f793cc8bf72..9d2a63c5bfbe3 100644 --- a/packages/provider-core/src/server/maintenance.ts +++ b/packages/provider-core/src/server/maintenance.ts @@ -12,6 +12,8 @@ export interface ProviderMaintenanceCapabilities { * `latestVersion`, then npm. */ readonly lookupLatestVersion?: Effect.Effect; + /** Separates installer release channels that share an update command. */ + readonly latestVersionCacheKey?: string; readonly compareVersions?: (current: string, latest: string) => number; /** Resolved executable path for advisories when no update command is proven. */ readonly installPath?: string; diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 7883d827d4e64..9447742ca354b 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -1,12 +1,13 @@ // @effect-diagnostics nodeBuiltinImport:off import { expect, it } from "@effect/vitest"; import * as NodeFS from "node:fs"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -44,8 +45,8 @@ const driver = (value: string) => ProviderDriverKind.make(value); // Shell stubs use POSIX paths; Windows temp paths cannot split on colon. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; const makeTempDir = (name: string) => - Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), + SecureRandom.SecureRandom.pipe( + Effect.flatMap((random) => random.uuidV4()), Effect.map((id) => NodePath.join(NodeFS.realpathSync(NodeOS.tmpdir()), `${name}-${id}`)), ); const isNativeTestCommandPath = @@ -131,7 +132,11 @@ function stdoutSpawner( } it.layer( - NodeServices.layer.pipe(Layer.merge(Layer.succeed(HostProcess.Environment, { PATH: "" }))), + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.succeed(HostProcess.Environment, { PATH: "" }), + ), )("providerMaintenance", (it) => { it.effect("prefers the installer's own latest version over the npm registry", () => resolveLatestProviderVersion({ ...manualPackageTool, latestVersion: "1.2.0" }).pipe( @@ -706,7 +711,7 @@ it.layer( maintenanceCapabilities: capabilities, }), ).toMatchObject({ status: "current", canUpdate: false, canInstallVersion: false }); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("stays unknown when choco search fails instead of asking npm", () => @@ -737,7 +742,7 @@ it.layer( ); expect(latest).toBeNull(); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -1127,6 +1132,34 @@ it.layer( }, ); + it.effect.skipIf(!symlinksSupported || windowsHost)( + "updates an npm-owned launcher in a native install location with npm", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-native-npm"); + const prefix = NodePath.join(tempDir, ".local"); + const packageBin = NodePath.join("node_modules", "@example", "native-package-tool", "bin"); + writeExecutable(NodePath.join(prefix, "lib", packageBin, "native-package-tool.exe")); + const launcher = NodePath.join(prefix, "bin", "native-package-tool"); + NodeFS.mkdirSync(NodePath.dirname(launcher), { recursive: true }); + NodeFS.symlinkSync( + NodePath.join("..", "lib", packageBin, "native-package-tool.exe"), + launcher, + ); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + nativePackageToolUpdate, + { binaryPath: launcher, env: { PATH: "" } }, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", prefix]), + lockKey: `npm-global:${normalizeCommandPath(prefix)}`, + }); + }), + ); + it.effect.skipIf(!symlinksSupported)( "prefers npm ownership over the Node keg the package lives under", () => diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 1810fff9a52c4..364b4cee9bf42 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -532,10 +532,11 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( : {}), }) : manual; - // A native-looking launcher can still delegate to a mise-owned install. + // A native-looking launcher can still delegate to an npm- or mise-owned install. if ( nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath) && + !npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName) && !commandPaths.some(isMiseCommandPath) && !(yield* isMiseWrapperScript(context)) ) { @@ -980,6 +981,7 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack */ function installerLatestVersionKey(capabilities: ProviderMaintenanceCapabilities) { if (!capabilities.lookupLatestVersion) return null; + if (capabilities.latestVersionCacheKey) return capabilities.latestVersionCacheKey; const update = capabilities.update; if (update) return [update.lockKey, update.executable, ...update.args].join("\0"); return capabilities.installPath diff --git a/packages/provider-core/src/server/notification.ts b/packages/provider-core/src/server/notification.ts index 30f6826278c7a..85cac547cfa37 100644 --- a/packages/provider-core/src/server/notification.ts +++ b/packages/provider-core/src/server/notification.ts @@ -217,6 +217,7 @@ export function notificationTurnItem( createdBy: _createdBy, creationSource: _creationSource, scheduledTaskId: _scheduledTaskId, + modelSwitch: _modelSwitch, ...base } = item; return { ...base, type: "notification", ...notification }; diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 13c6f5beed026..0daab5da6b595 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -15,7 +15,7 @@ export const T3_CODE_ORCHESTRATION_SECTIONS: ReadonlyArray = [ Ordinary coding tasks need no T3 tools. -- Subagents: use your native tool when the child uses your provider, a model it offers, and no settings beyond yours, and it finishes in this turn (Claude \`Agent\` always with \`run_in_background:false\`; Codex \`spawn_agent\`, then wait). Use \`delegate_task\` for another provider's model, user-asked settings, a separate checkout (\`isolation:"worktree"\`), background work, or \`delegate_task\` by name. A native type running another provider's model counts too. Workflow only when asked. +- Subagents: use your native tool when the child uses your provider, a model it offers, and no settings beyond yours (Claude \`Agent\`, \`run_in_background:false\`; Codex \`spawn_agent\`, then wait). Use \`delegate_task\` for another provider's model, user-asked settings, a separate checkout (\`isolation:"worktree"\`), or \`delegate_task\` by name. A native type on another provider's model counts too. Background agent: \`delegate_task\`, never a background \`Agent\`. Workflow only when asked. - Call \`orchestrator_capabilities\` once per thread, before the first \`delegate_task\`. Again only after an error or a user-named provider change. Settings go in \`target.options\`. Provider or model unavailable: say so. Never switch unasked, or fall back to native tools, Workflow, or CLIs. Never make a child's checkout in the shell or in \`task\`. - Every new delegate review or re-check is a new \`delegate_task\` (brief, findings, objections), never a \`t3_thread_send\`. Fix small review findings yourself; start no delegate for them. When told to proceed without a delegate, or to run fewer, \`task_cancel\` the extras. Start no replacement. - Follow a running delegate's \`next\`. Do not poll it (\`task_status\`, \`sleep\`, watchers, Monitor, ScheduleWakeup). Stop a delegate run you replace or no longer need with \`task_cancel\`, never \`t3_thread_interrupt\` on a \`childThreadId\`. diff --git a/packages/provider-core/src/server/providerAuthFlow.test.ts b/packages/provider-core/src/server/providerAuthFlow.test.ts index 968eb04f78b4d..cf4a4b699d7f0 100644 --- a/packages/provider-core/src/server/providerAuthFlow.test.ts +++ b/packages/provider-core/src/server/providerAuthFlow.test.ts @@ -1,4 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, it } from "@effect/vitest"; import { ProviderInstanceId, @@ -41,7 +43,7 @@ it.effect("distinguishes pending method discovery from an agent with no sign-in Effect.map(Option.getOrThrow), ); assert.deepEqual(ready.methods, []); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const makeHarness = Effect.gen(function* () { @@ -112,7 +114,7 @@ it.effect("requires owner consent and provider verification before success", () yield* phase("verifying"); yield* Deferred.succeed(verified, undefined); assert.strictEqual((yield* phase("succeeded")).phase, "succeeded"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("cancellation rejects late consent and permits another login", () => @@ -132,7 +134,7 @@ it.effect("cancellation rejects late consent and permits another login", () => ); const next = yield* controller.start("other"); assert.notStrictEqual(next.flowId, state.flowId); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("closes admitted provider processes and blocks new ones while login is pending", () => @@ -152,7 +154,7 @@ it.effect("closes admitted provider processes and blocks new ones while login is assert.isTrue( (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure", ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("shared credential invalidation closes admitted processes before permitting reuse", () => @@ -169,7 +171,7 @@ it.effect("shared credential invalidation closes admitted processes before permi yield* controller.invalidate!; assert.isTrue(closed); assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -234,7 +236,7 @@ it.effect.each([ assert.isNull(verifying.interaction); assert.notInclude(verifying.message ?? "", "private-token"); yield* controller.cancel("owner", state.flowId!); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("shows a device code only to its owner without treating it as authenticated", () => @@ -268,7 +270,7 @@ it.effect("shows a device code only to its owner without treating it as authenti assert.isNull(other.interaction); assert.isNull(other.authorizationUrl); yield* controller.cancel("owner", start.flowId!); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -302,7 +304,7 @@ it.effect.each([ Effect.map(Option.getOrThrow), ); assert.strictEqual(failed.message, message); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const makeBlockingResponseHarness = Effect.gen(function* () { @@ -393,7 +395,7 @@ it.effect.each(["cancel", "logout"] as const)( assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); assert.isFalse(yield* harness.controller.isChangingCredentials!); assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -415,7 +417,7 @@ it.effect( assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); yield* harness.controller.withAccess!(Effect.void); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("closing the controller scope interrupts and drains its adapter response", () => @@ -431,7 +433,7 @@ it.effect("closing the controller scope interrupts and drains its adapter respon yield* Fiber.join(closing); assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("a failed method refresh keeps the last discovered methods", () => @@ -458,7 +460,7 @@ it.effect("a failed method refresh keeps the last discovered methods", () => .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); assert.deepEqual(state.methods, [method]); assert.strictEqual(state.message, "interrupted"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("rebuilding a controller leaves sessions it admitted to their owners", () => @@ -475,7 +477,7 @@ it.effect("rebuilding a controller leaves sessions it admitted to their owners", ); yield* Scope.close(scope, Exit.void); assert.isFalse(closed); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("profile import stops owned sessions and gates access until credentials are saved", () => @@ -514,5 +516,5 @@ it.effect("profile import stops owned sessions and gates access until credential yield* controller.withAccess!(Effect.succeed("new credential process")), "new credential process", ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/packages/provider-core/src/server/providerAuthFlow.ts b/packages/provider-core/src/server/providerAuthFlow.ts index 1ee54c952307a..160221faf612f 100644 --- a/packages/provider-core/src/server/providerAuthFlow.ts +++ b/packages/provider-core/src/server/providerAuthFlow.ts @@ -10,7 +10,7 @@ import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; @@ -72,7 +72,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { readonly timeoutMs?: number; }) { const scope = yield* Scope.Scope; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const lock = yield* Semaphore.make(1); const timeoutMs = options.timeoutMs ?? 300_000; const empty: ProviderAuthState = { @@ -238,7 +238,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { operation: "start", detail: "Provider setup is already in progress.", }); - const id = yield* crypto.randomUUIDv4().pipe( + const id = yield* random.uuidV4().pipe( Effect.mapError( () => new ProviderSetupError({ diff --git a/packages/provider-core/src/server/runtimeInstructions.test.ts b/packages/provider-core/src/server/runtimeInstructions.test.ts index dd57ad03c1f9c..534398e92d2cf 100644 --- a/packages/provider-core/src/server/runtimeInstructions.test.ts +++ b/packages/provider-core/src/server/runtimeInstructions.test.ts @@ -3,6 +3,17 @@ import { assert, describe, it } from "@effect/vitest"; import { buildRuntimeInstructions } from "./runtimeInstructions.ts"; describe("buildRuntimeInstructions", () => { + it("leaves PR linking to a session that already carries it, and keeps runtime identity", () => { + const instructions = buildRuntimeInstructions({ + harness: "Antigravity", + model: "gemini-3.8-flash", + pullRequestLinking: false, + }); + + assert.include(instructions, "through the Antigravity harness, as gemini-3.8-flash."); + assert.notInclude(instructions, ""); + }); + it("links PRs the agent creates, takes over, or pushes to, and no others", () => { const instructions = buildRuntimeInstructions({ harness: "Codex" }); diff --git a/packages/provider-core/src/server/runtimeInstructions.ts b/packages/provider-core/src/server/runtimeInstructions.ts index 95f57f1ebde91..28866a7a038a5 100644 --- a/packages/provider-core/src/server/runtimeInstructions.ts +++ b/packages/provider-core/src/server/runtimeInstructions.ts @@ -1,4 +1,5 @@ -const PULL_REQUEST_LINKING_INSTRUCTIONS = ` +/** Session-invariant; harnesses with a native system-prompt append can carry it once per session. */ +export const PULL_REQUEST_LINKING_INSTRUCTIONS = ` Link each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. New watches default to mode "checks": stop and send a final result when all reported checks finish, even failed or cancelled ones; empty results keep waiting. Use mode "review" for ongoing checks and review monitoring after CI finishes. Omitting mode on an existing watch keeps its mode. Finished checks are not approval to merge. After pushing a fix, start a new checks watch if needed. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is the \`report\` line from pull_request_status. Links persist across pushes; active watches do too. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch. `; @@ -16,6 +17,8 @@ export function buildRuntimeInstructions(runtime: { readonly model?: string | undefined; readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; + /** False when the session's system prompt already carries the linking instructions. */ + readonly pullRequestLinking?: boolean; }): string { const harness = toSingleLine(runtime.harness); const model = toSingleLine(runtime.model ?? ""); @@ -25,7 +28,10 @@ export function buildRuntimeInstructions(runtime: { modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}\n\n${GITHUB_READS_INSTRUCTIONS}`; + const runtimeInfo = `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.`; + return runtime.pullRequestLinking === false + ? `${runtimeInfo}\n\n${GITHUB_READS_INSTRUCTIONS}` + : `${runtimeInfo}\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}\n\n${GITHUB_READS_INSTRUCTIONS}`; } function toSingleLine(value: string): string { diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index b619c3b61fdb4..90122cbe57e66 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -49,7 +49,7 @@ "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { - "@cursor/sdk": "1.0.38", + "@cursor/sdk": "1.2.0", "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", diff --git a/packages/provider-cursor/src/server/CursorAccountReader.ts b/packages/provider-cursor/src/server/CursorAccountReader.ts index f7601e7873d55..183819b05c8c8 100644 --- a/packages/provider-cursor/src/server/CursorAccountReader.ts +++ b/packages/provider-cursor/src/server/CursorAccountReader.ts @@ -1,5 +1,5 @@ import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as HttpClient from "effect/http/HttpClient"; @@ -25,7 +25,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const keychain = yield* CursorKeychain.CursorKeychain; const context = yield* Effect.context< - FileSystem.FileSystem | Crypto.Crypto | HttpClient.HttpClient + FileSystem.FileSystem | Digest.Digest | HttpClient.HttpClient >(); return CursorAccountReader.of({ read: Effect.fn("CursorAccountReader.read")(function (credentialSource, sinceMs, untilMs) { diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 791121a7140ec..f48d7baf1b24a 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -89,6 +89,8 @@ export interface CursorAgentSdkSendInput { readonly message: string | SDKUserMessage; readonly options?: Omit; readonly onDelta?: (update: InteractionUpdate) => Effect.Effect; + /** Runs `agent.summarize()` instead, which takes no message or options. */ + readonly summarize?: boolean; } export interface CursorAgentSdkRun { @@ -139,6 +141,8 @@ export interface CursorAgentSdkLoggedAgentOptions { export interface CursorAgentSdkLoggedSendOptions { readonly model?: SendOptions["model"]; readonly mode?: SendOptions["mode"]; + readonly hasMcpServers?: boolean; + readonly mcpServerNames?: ReadonlyArray; readonly local?: { readonly force?: boolean; readonly hasCustomTools?: boolean; @@ -298,9 +302,12 @@ export function loggedCursorSendOptions( if (options === undefined) { return {}; } + const mcpServerNames = + options.mcpServers === undefined ? undefined : Object.keys(options.mcpServers).toSorted(); return { ...(options.model === undefined ? {} : { model: options.model }), ...(options.mode === undefined ? {} : { mode: options.mode }), + ...(mcpServerNames === undefined ? {} : { hasMcpServers: true, mcpServerNames }), ...(options.local === undefined ? {} : { @@ -466,16 +473,18 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu const startRun = () => Effect.tryPromise({ try: () => - agent.send(sendInput.message, { - ...sendInput.options, - onDelta: async ({ update }) => { - if (!callbacksReady) { - pendingUpdates.push(update); - return; - } - await dispatchUpdate(update); - }, - }), + sendInput.summarize === true + ? agent.summarize() + : agent.send(sendInput.message, { + ...sendInput.options, + onDelta: async ({ update }) => { + if (!callbacksReady) { + pendingUpdates.push(update); + return; + } + await dispatchUpdate(update); + }, + }), catch: (cause) => runnerError(cause, "run.start"), }); const run = yield* startRun().pipe( diff --git a/packages/provider-cursor/src/server/CursorSdk.test.ts b/packages/provider-cursor/src/server/CursorSdk.test.ts index 0f8871d4ad81e..bf5229c9642fd 100644 --- a/packages/provider-cursor/src/server/CursorSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorSdk.test.ts @@ -35,6 +35,21 @@ describe("CursorSdk unhandled rejection guard", () => { }), ); + it.effect("keeps the server running when the SDK writes after its request stream ended", () => + Effect.gen(function* () { + // Shaped like `ConnectError.from(e, Code.Aborted)` from the SDK's connect-node body pump. + const cause = Object.assign(new Error("write after end"), { + code: "ERR_STREAM_WRITE_AFTER_END", + }); + const reason = Object.assign(new Error("[aborted] write after end", { cause }), { + name: "ConnectError", + code: 10, + rawMessage: "write after end", + }); + assert.deepStrictEqual(yield* rejectThroughGuard(reason), []); + }), + ); + it.effect("still exits on other rejections", () => Effect.gen(function* () { assert.deepStrictEqual(yield* rejectThroughGuard(new Error("WritableIterable is closed")), [ @@ -42,4 +57,15 @@ describe("CursorSdk unhandled rejection guard", () => { ]); }), ); + + it.effect("still exits on aborted requests other than a write after end", () => + Effect.gen(function* () { + const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + const reason = Object.assign(new Error("[aborted] socket hang up", { cause }), { + name: "ConnectError", + code: 10, + }); + assert.deepStrictEqual(yield* rejectThroughGuard(reason), [[1]]); + }), + ); }); diff --git a/packages/provider-cursor/src/server/CursorSdk.ts b/packages/provider-cursor/src/server/CursorSdk.ts index f341a6ffe5256..b59d09725243a 100644 --- a/packages/provider-cursor/src/server/CursorSdk.ts +++ b/packages/provider-cursor/src/server/CursorSdk.ts @@ -32,12 +32,23 @@ function isCursorShellSpawnFailure(reason: unknown): boolean { } /** - * After a local exec fails, the SDK writes a final frame to the exec's stream from - * a promise nothing awaits. If that stream has already closed, the write rejects. - * The SDK's class is minified, so only its `name` identifies it. + * The SDK writes final frames from promises nothing awaits. A closed stream rejects them + * with WriteIterableClosedError; an ended request body rejects them with the Aborted + * ConnectError its connect-node pump wraps around ERR_STREAM_WRITE_AFTER_END. The classes + * are minified, so only `name` identifies them. */ function isCursorClosedStreamWrite(reason: unknown): boolean { - return reason instanceof Error && reason.name === "WriteIterableClosedError"; + if (!(reason instanceof Error)) { + return false; + } + if (reason.name === "WriteIterableClosedError") { + return true; + } + return ( + reason.name === "ConnectError" && + reason.cause instanceof Error && + Reflect.get(reason.cause, "code") === "ERR_STREAM_WRITE_AFTER_END" + ); } function onUnhandledRejection(reason: unknown): void { @@ -46,7 +57,7 @@ function onUnhandledRejection(reason: unknown): void { return; } if (isCursorClosedStreamWrite(reason)) { - console.error("Cursor SDK wrote to a closed exec stream. The server will keep running."); + console.error("Cursor SDK wrote to a closed stream. The server will keep running."); return; } if (process.listenerCount("unhandledRejection") > 1) { diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 07fa215f2eeb2..0bb9550ba9d71 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -1,11 +1,14 @@ +import type { ProviderInstanceId } from "@t3tools/contracts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Digest from "effect/crypto/Digest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -25,6 +28,7 @@ import { type CursorCredentialSource, } from "./accountCache.ts"; import * as CursorAccountReader from "./CursorAccountReader.ts"; +import { readStoredCursorApiKey } from "./credentialStore.ts"; const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; @@ -36,6 +40,10 @@ export class CursorUsageAccounts extends Context.Service< CursorUsageAccounts, { readonly scan: (input: { + readonly instances: ReadonlyArray<{ + readonly instanceId: ProviderInstanceId; + readonly environment: NodeJS.ProcessEnv; + }>; readonly keychainUsageEnabled: boolean; readonly windowStartMs: number; readonly retentionCutoffMs: number; @@ -52,6 +60,7 @@ const make = Effect.gen(function* () { const accountReader = yield* CursorAccountReader.CursorAccountReader; const hostEnvironment = yield* HostProcess.Environment; const platform = yield* HostProcess.Platform; + const digest = yield* Digest.Digest; const cachePath = path.join(host.paths.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); const caches = new Map(); @@ -187,12 +196,28 @@ const make = Effect.gen(function* () { awaitRefresh: boolean, ) { // A Keychain login outlives an uninstalled Cursor, so it also needs Cursor's local directory. - const localLogin = typeof credential === "string" ? credential : path.dirname(authPath); - if (!(yield* fileSystem.exists(localLogin).pipe(Effect.orElseSucceed(() => true)))) { + const localLogin = + typeof credential === "string" + ? credential + : credential.kind === "keychain" + ? path.dirname(authPath) + : null; + if ( + localLogin !== null && + !(yield* fileSystem.exists(localLogin).pipe(Effect.orElseSucceed(() => true))) + ) { return null; } yield* ensureLoaded; - const credentialKey = typeof credential === "string" ? credential : "keychain"; + const credentialKey = + typeof credential === "string" + ? credential + : credential.kind === "apiKey" + ? // A fingerprint, so a replaced key never reads the previous account's cache. + `api-key:${Hex.encode( + yield* digest.digest(Digest.sha256, new TextEncoder().encode(credential.apiKey)), + )}` + : "keychain"; const nowMs = yield* Clock.currentTimeMillis; const recentFailure = failures.get(credentialKey); let refreshing = false; @@ -238,7 +263,13 @@ const make = Effect.gen(function* () { }); const scan: CursorUsageAccounts["Service"]["scan"] = Effect.fn("CursorUsageAccounts.scan")( - function* ({ keychainUsageEnabled, windowStartMs, retentionCutoffMs, awaitRefresh }) { + function* ({ + instances, + keychainUsageEnabled, + windowStartMs, + retentionCutoffMs, + awaitRefresh, + }) { const home = yield* HostProcess.HomeDirectory; const userHome = (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; @@ -255,11 +286,46 @@ const make = Effect.gen(function* () { platform === "darwin" ? path.join(userHome, ".cursor", "auth.json") : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); + // T3's Cursor sign-in and CURSOR_API_KEY name the account the agent uses, so they win. + const apiKeys = yield* Effect.forEach(instances, ({ instanceId, environment }) => + environment.CURSOR_API_KEY?.trim() + ? Effect.succeed(environment.CURSOR_API_KEY.trim()) + : readStoredCursorApiKey(instanceId).pipe( + Effect.provideService(ProviderHost.ProviderHost, host), + Effect.orElseSucceed(() => undefined), + ), + ); + const uniqueApiKeys = [...new Set(apiKeys.filter((apiKey) => apiKey !== undefined))]; + if (uniqueApiKeys.length > 0) { + const sources: ReadonlyArray = yield* Effect.forEach( + uniqueApiKeys, + (apiKey) => + accountSource( + { kind: "apiKey", apiKey }, + authPath, + windowStartMs, + retentionCutoffMs, + awaitRefresh, + ), + ); + // Keys for one account share its source, and a repeated source would count sessions twice. + // A failed key can hold older records, so a current read wins, then a refresh the client + // must follow up on. + const rank = (source: ProviderUsageScan) => + source.status === "ok" ? 2 : source.refreshing ? 1 : 0; + const byAccount = new Map(); + for (const source of sources) { + if (source === null) continue; + const kept = byAccount.get(source.dir); + if (kept === undefined || rank(source) > rank(kept)) { + byAccount.set(source.dir, source); + } + } + return [...byAccount.values()]; + } const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; const loginUnavailable = - Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || - Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || - credentialStore === "memory"; + Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || credentialStore === "memory"; const useKeychain = platform === "darwin" && credentialStore !== "file"; if (useKeychain && !loginUnavailable && !keychainUsageEnabled) { return [ diff --git a/packages/provider-cursor/src/server/accountCache.ts b/packages/provider-cursor/src/server/accountCache.ts index bcbb1ca402b17..b2ff2fbd6b3a2 100644 --- a/packages/provider-cursor/src/server/accountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -2,7 +2,10 @@ import { cursorRateModel } from "./accountUsage.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; -export type CursorCredentialSource = string | { readonly kind: "keychain" }; +export type CursorCredentialSource = + | string + | { readonly kind: "keychain" } + | { readonly kind: "apiKey"; readonly apiKey: string }; export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts index 642189484bf18..a2ccc42cc7502 100644 --- a/packages/provider-cursor/src/server/accountUsage.test.ts +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -1,13 +1,17 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, describe, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import * as CursorKeychain from "./CursorKeychain.ts"; import { readCursorAccountUsage } from "./accountUsage.ts"; @@ -114,7 +118,7 @@ describe("Cursor account history", () => { }); assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); assert.isFalse(result.accountKey?.includes("demo") ?? true); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reads Cursor account history beyond 100 pages", () => @@ -133,7 +137,7 @@ describe("Cursor account history", () => { assert.isNull(result.error); assert.strictEqual(requests, 101); assert.deepStrictEqual(result.records, []); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("accepts confirmed empty Cursor usage but rejects error envelopes", () => @@ -167,7 +171,7 @@ describe("Cursor account history", () => { assert.isNotNull(result.error); assert.deepStrictEqual(result.records, []); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("requires a terminal Cursor page after a full page reaches the reported count", () => @@ -193,7 +197,7 @@ describe("Cursor account history", () => { assert.isNull(result.error); assert.strictEqual(result.records.length, 1000); assert.strictEqual(requests, 2); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -244,7 +248,7 @@ describe("Cursor account history", () => { }); assert.isNotNull(inconsistent.error); assert.deepStrictEqual(inconsistent.records, []); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -279,11 +283,59 @@ describe("Cursor account history", () => { ); assert.isTrue(missing.missing); assert.isFalse(requested); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); describe("readCursorAccountUsage", () => { + it.effect("reads an API key's account history through Cursor's API", () => + Effect.gen(function* () { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + const requests: Array = []; + const result = yield* readAccountUsage( + { kind: "apiKey", apiKey: "api-key" }, + 0, + 1781000000000, + ({ url, headers }) => { + requests.push([url, headers.get("authorization")]); + return url.endsWith("/auth/exchange_user_api_key") + ? Response.json({ accessToken }) + : Response.json({ + totalUsageEventsCount: 1, + usageEventsDisplay: [ + { timestamp: "1780000000000", model: "gpt-5", tokenUsage: { outputTokens: 4 } }, + ], + }); + }, + ); + assert.isNull(result.error); + assert.deepStrictEqual(requests, [ + ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], + [ + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetFilteredUsageEvents", + `Bearer ${accessToken}`, + ], + ]); + assert.strictEqual(result.records[0]?.totals.outputTokens, 4); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("stops waiting for an API key exchange that never answers", () => + Effect.gen(function* () { + const read = yield* Effect.forkChild( + readAccountUsage( + { kind: "apiKey", apiKey: "api-key" }, + 0, + 1781000000000, + () => new Promise(() => {}), + ), + ); + yield* TestClock.adjust("10 seconds"); + const result = yield* Fiber.join(read); + assert.strictEqual(result.error, "Cursor account usage could not be read."); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + it.effect("asks for Keychain approval when the prompt goes unanswered", () => Effect.gen(function* () { const result = yield* readAccountUsage( @@ -301,7 +353,7 @@ describe("readCursorAccountUsage", () => { missing: false, error: "Allow Keychain access on the Mac running T3 Code, then refresh.", }); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reads the pages behind the first together and keeps them in page order", () => @@ -335,7 +387,7 @@ describe("readCursorAccountUsage", () => { result.records.map((record) => record.timestampMs), Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("ends like a page-by-page read when a page fails, leaving no request open", () => @@ -375,6 +427,6 @@ describe("readCursorAccountUsage", () => { assert.deepStrictEqual(result.records, []); assert.strictEqual(open, 0); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index 65f1470b94c94..2719960eed033 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -1,5 +1,5 @@ import type { UsageRecord } from "@t3tools/provider-core/server/usage"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -13,6 +13,8 @@ import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import type { CursorCredentialSource } from "./accountCache.ts"; +import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; import * as CursorKeychain from "./CursorKeychain.ts"; function object(value: unknown): Record { @@ -57,8 +59,8 @@ const billedEventIdentity = (...fields: readonly unknown[]) => JSON.stringify(fi const textEncoder = new TextEncoder(); const accountHash = Effect.fn("accountHash")(function* (value: string) { - const crypto = yield* Crypto.Crypto; - return Hex.encode(yield* crypto.digest("SHA-256", textEncoder.encode(value))); + const digest = yield* Digest.Digest; + return Hex.encode(yield* digest.digest(Digest.sha256, textEncoder.encode(value))); }); function canonicalJson(value: unknown): string { @@ -90,7 +92,7 @@ const DASHBOARD_USAGE_URL = "https://cursor.com/api/dashboard/get-filtered-usage const NO_REDIRECT: RequestInit = { redirect: "error" }; export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* ( - credentialSource: string | { readonly kind: "keychain" }, + credentialSource: CursorCredentialSource, sinceMs: number, endDate: number, keychainToken: Effect.Effect< @@ -102,7 +104,7 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi ): Effect.fn.Return< CursorAccountUsageReadResult, never, - FileSystem.FileSystem | Crypto.Crypto | HttpClient.HttpClient + FileSystem.FileSystem | Digest.Digest | HttpClient.HttpClient > { const fileSystem = yield* FileSystem.FileSystem; const httpClient = yield* HttpClient.HttpClient; @@ -120,7 +122,14 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi Effect.flatMap(decodeJson), Effect.map((credentials) => object(credentials).accessToken), ) - : keychainToken, + : credentialSource.kind === "apiKey" + ? exchangeCursorApiKey(credentialSource.apiKey).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.mapError( + () => new CursorAccountUsageInvalidError({ reason: "Invalid API key" }), + ), + ) + : keychainToken, ); if (Result.isFailure(credential)) { const cause = credential.failure; @@ -136,9 +145,11 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi ? null : typeof credentialSource === "string" ? "Cursor credentials could not be read." - : CursorKeychain.isCursorKeychainTimeoutError(cause) - ? "Allow Keychain access on the Mac running T3 Code, then refresh." - : "Cursor Keychain credentials could not be read.", + : credentialSource.kind === "apiKey" + ? "Cursor account usage could not be read." + : CursorKeychain.isCursorKeychainTimeoutError(cause) + ? "Allow Keychain access on the Mac running T3 Code, then refresh." + : "Cursor Keychain credentials could not be read.", }; } const accessToken = credential.success; @@ -168,6 +179,21 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi accountKey = yield* accountHash(subject); if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) return yield* invalid("Invalid date window"); + // The web dashboard rejects API-key tokens; Cursor's RPC API accepts them. + const pageRequest = + typeof credentialSource !== "string" && credentialSource.kind === "apiKey" + ? HttpClientRequest.post( + `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, + ).pipe( + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeader("connect-protocol-version", "1"), + ) + : HttpClientRequest.post(DASHBOARD_USAGE_URL).pipe( + HttpClientRequest.setHeaders({ + Origin: "https://cursor.com", + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, + }), + ); const records: UsageRecord[] = []; const occurrences = new Map(); const pages: unknown[][] = []; @@ -183,11 +209,7 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi } const response = yield* httpClient .execute( - HttpClientRequest.post(DASHBOARD_USAGE_URL).pipe( - HttpClientRequest.setHeaders({ - Origin: "https://cursor.com", - Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, - }), + pageRequest.pipe( HttpClientRequest.bodyText( pageRequestBody(page, pageSize, sinceMs, endDate), "application/json", diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 8881c409da529..e85cdbe41763a 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -219,7 +219,7 @@ describe("CursorAdapterV2", () => { Stream.takeUntil((event) => event.type === "turn.terminal"), Stream.runCollect, ); - assert.isFalse(sentWithMcpOverride); + assert.isTrue(sentWithMcpOverride); const rows = events.filter((event) => event.type === "subagent.updated"); assert.equal(rows[0]?.subagent.status, "running"); assert.equal(rows[0]?.subagent.model, model ?? null); @@ -510,11 +510,11 @@ describe("CursorAdapterV2", () => { ), ); - it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => + it.effect("fails standalone SDK transport diagnostics and compacts with SDK summarize", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" }); - const sentMessages: Array = []; + const summarizeRequests: Array = []; let sendCalls = 0; const instanceId = ProviderInstanceId.make("cursor"); const threadId = ThreadId.make("cursor-transport-error-thread"); @@ -538,9 +538,7 @@ describe("CursorAdapterV2", () => { send: (input) => Effect.sync(() => { sendCalls += 1; - sentMessages.push( - typeof input.message === "string" ? input.message : input.message.text, - ); + summarizeRequests.push(input.summarize === true); const runId = `native-cursor-run-${sendCalls}`; return { agentId: "native-cursor-error", @@ -551,9 +549,10 @@ describe("CursorAdapterV2", () => { status: "finished" as const, model: { id: "composer-2.5" }, durationMs: 1, - ...(sendCalls === 1 - ? { result: "Error: RetriableError: WritableIterable is closed" } - : {}), + result: + sendCalls === 1 + ? "Error: RetriableError: WritableIterable is closed" + : "Summary of the conversation so far", }), cancel: Effect.void, }; @@ -646,11 +645,12 @@ describe("CursorAdapterV2", () => { attachments: [], }, }); - yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, + const compactionEvents = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, ); - assert.equal(sentMessages[1], "/compress"); + assert.deepEqual(summarizeRequests, [false, true]); + assert.notInclude(JSON.stringify(compactionEvents), "Summary of the conversation so far"); }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index c4d18644bf3d3..94889fd73e155 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2385,15 +2385,18 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( hasDeviceTools, delivered, ); + const compacted = message === "/compress"; const pendingUpdates: Array = []; let context: ActiveCursorTurn | null = null; - // MCP send overrides replace the ambient servers loaded when the agent opens. + // Create-time and send-time mcpServers merge with settingSources (SDK >= 1.0.39). const sdkRun = yield* agent.session .send({ message, + summarize: compacted, options: { model: cursorSdkModelSelection(turnInput.modelSelection), mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", + ...(mcpServers === undefined ? {} : { mcpServers }), }, onDelta: (update) => { if (context === null) { @@ -2405,7 +2408,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }, }) .pipe(Effect.mapError(classifyCursorModelRejection(turnInput.modelSelection))); - const compacted = message === "/compress"; yield* Ref.set(told, { agent: agent.nativeThreadId, orchestration: !compacted && (delivered.orchestration || mcpServers !== undefined), @@ -2469,6 +2471,8 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( if ( context !== null && context.assistant.nextSegment === 0 && + // A summarize run's result is the summary, not an answer. + !compacted && // Text from a resume that made no progress is not the turn's answer. !isResumeFailureAfterTurnEnded(context, result) && result.result !== undefined && diff --git a/packages/provider-cursor/src/server/apiKey.ts b/packages/provider-cursor/src/server/apiKey.ts new file mode 100644 index 0000000000000..06c93e1f3bc55 --- /dev/null +++ b/packages/provider-cursor/src/server/apiKey.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +export const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; + +const ApiKeyExchange = Schema.Struct({ accessToken: Schema.NonEmptyString }); + +/** Trades a user API key for the access token Cursor's RPCs accept, as `@cursor/sdk` does. */ +export const exchangeCursorApiKey = Effect.fn("exchangeCursorApiKey")(function* (apiKey: string) { + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.post(`${DEFAULT_CURSOR_API_ENDPOINT}/auth/exchange_user_api_key`).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.bodyJsonUnsafe({}), + ), + ); + const { accessToken } = yield* HttpClientResponse.schemaBodyJson(ApiKeyExchange)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return accessToken; +}, Effect.timeout("10 seconds")); diff --git a/packages/provider-cursor/src/server/auth.test.ts b/packages/provider-cursor/src/server/auth.test.ts index d27eeb2f2b442..1839795941ecd 100644 --- a/packages/provider-cursor/src/server/auth.test.ts +++ b/packages/provider-cursor/src/server/auth.test.ts @@ -1,5 +1,7 @@ import type { SdkLoginOptions, StoredSdkCredentials } from "@cursor/sdk"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { expect, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -79,7 +81,7 @@ const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) = Effect.map(Option.getOrThrow), ); -it.layer(NodeServices.layer)("CursorAuth", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("CursorAuth", (it) => { it.effect("describes a browser sign-in as CURSOR_API_KEY for another machine", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts index 18d900ef7dfda..76f55d31eac74 100644 --- a/packages/provider-cursor/src/server/auth.ts +++ b/packages/provider-cursor/src/server/auth.ts @@ -6,7 +6,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -52,7 +52,7 @@ export interface CursorAuthOptions { export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) { const scope = yield* Scope.Scope; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const lock = yield* Semaphore.make(1); const configuredKey = options.apiKey?.trim() || undefined; const emptyState: ProviderAuthState = { @@ -348,7 +348,7 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu operation: "start", detail: "Cursor setup is already in progress.", }); - const id = yield* crypto.randomUUIDv4().pipe( + const id = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new ProviderSetupError({ diff --git a/packages/provider-cursor/src/server/credentialStore.ts b/packages/provider-cursor/src/server/credentialStore.ts index d0638132f1b6f..d995a49f0d215 100644 --- a/packages/provider-cursor/src/server/credentialStore.ts +++ b/packages/provider-cursor/src/server/credentialStore.ts @@ -1,5 +1,6 @@ import type { SdkCredentialStore } from "@cursor/sdk"; import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -90,3 +91,22 @@ export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")( }; return { store, binding: credentials.binding }; }); + +/** The unexpired API key from this instance's T3 sign-in, read without loading the SDK. */ +export const readStoredCursorApiKey = Effect.fn("readStoredCursorApiKey")(function* ( + instanceId: ProviderInstanceId, +) { + const stored = yield* (yield* (yield* ProviderHost.ProviderHost).credentials( + "cursor", + instanceId, + )).get; + if (Option.isNone(stored)) return undefined; + const credentials = yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe( + Effect.option, + ); + if (Option.isNone(credentials)) return undefined; + const { apiKey, apiKeyExpiresAtMs } = credentials.value; + return apiKeyExpiresAtMs === undefined || apiKeyExpiresAtMs > (yield* Clock.currentTimeMillis) + ? apiKey + : undefined; +}); diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index 8f034ff185e98..d273e4b278194 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import type { AgentOptions, SDKModel } from "@cursor/sdk"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { @@ -42,7 +43,7 @@ import { loadCursorSdk } from "./sdk.ts"; const { Cursor } = loadCursorSdk(); const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(IdAllocator.layer), Layer.provideMerge(McpProviderSessions.layer), Layer.provideMerge(CursorSdk.layer), diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 51c079a51cb84..088c97c685c58 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/contracts"; import { CursorSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -51,7 +51,7 @@ const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ export type CursorDriverEnv = | CursorAdapterV2DriverEnv - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient @@ -74,10 +74,11 @@ export const CursorDriver: ProviderDriver< usage: { kind: "scan", provider: "cursor", - scan: ({ settings, windowStartMs, retentionCutoffMs, awaitRefresh }) => + scan: ({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }) => CursorUsageAccounts.CursorUsageAccounts.pipe( Effect.flatMap((accounts) => accounts.scan({ + instances, keychainUsageEnabled: settings.cursorKeychainUsageEnabled, windowStartMs, retentionCutoffMs, diff --git a/packages/provider-cursor/src/server/skills.test.ts b/packages/provider-cursor/src/server/skills.test.ts index 8d1359b1f577e..0bbd1bfeb51ea 100644 --- a/packages/provider-cursor/src/server/skills.test.ts +++ b/packages/provider-cursor/src/server/skills.test.ts @@ -3,7 +3,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "vite-plus/test"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import type * as Scope from "effect/Scope"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -173,6 +175,84 @@ describe("Cursor skills", () => { }), )); + it("finds sibling skills when one skill ships more files than the scan budget", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDirectory = (prefix: string) => + fileSystem + .makeTempDirectoryScoped({ directory: NodeOS.tmpdir(), prefix }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const userHome = yield* tempDirectory("cursor-skills-home-"); + const workspace = yield* tempDirectory("cursor-skills-workspace-"); + const root = path.join(workspace, ".cursor", "skills"); + const artifacts = path.join(root, "a-large", "artifacts"); + yield* fileSystem.makeDirectory(artifacts, { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, "a-large", "SKILL.md"), "---\n---\n"); + yield* fileSystem.makeDirectory(path.join(root, "b-small"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, "b-small", "SKILL.md"), "---\n---\n"); + // More ordinary files than the whole-scan entry budget of 10,000. + yield* Effect.forEach( + Array.from({ length: 11_000 }, (_, index) => path.join(artifacts, `file-${index}.txt`)), + (file) => fileSystem.writeFileString(file, ""), + { concurrency: 64, discard: true }, + ); + + const expected = [ + { + name: "a-large", + path: path.join(root, "a-large", "SKILL.md"), + scope: "project", + enabled: true, + }, + { + name: "b-small", + path: path.join(root, "b-small", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]; + expect(yield* discoverCursorSkills(workspace, { HOME: userHome })).toEqual(expected); + expect(yield* probeCursorSkills(workspace, { HOME: userHome })).toEqual(expected); + }), + )); + + it("stops a scan of a huge file tree instead of statting every file", async () => { + const root = "/workspace/.cursor/skills"; + let stats = 0; + const fileSystem = FileSystem.layerNoop({ + realPath: (path) => Effect.succeed(path), + readDirectory: (path) => + path === root + ? Effect.succeed(Array.from({ length: 200_000 }, (_, index) => `file-${index}`)) + : Effect.succeed([]), + stat: (path) => { + stats += 1; + if (path === root) return Effect.succeed({ type: "Directory" } as FileSystem.File.Info); + if (path.startsWith(`${root}/file-`)) { + return Effect.succeed({ type: "File" } as FileSystem.File.Info); + } + return Effect.fail( + PlatformError.systemError({ _tag: "NotFound", module: "FileSystem", method: "stat" }), + ); + }, + }); + + const result = await probeCursorSkills("/workspace", { HOME: "/home" }).pipe( + Effect.result, + Effect.provide(Layer.mergeAll(fileSystem, Path.layer)), + Effect.runPromise, + ); + + expect(result).toMatchObject({ + _tag: "Failure", + failure: { reason: "scan-budget-exhausted" }, + }); + expect(stats).toBeGreaterThanOrEqual(100_000); + expect(stats).toBeLessThanOrEqual(100_000); + }); + it.skipIf(!symlinksSupported)( "treats a symlinked skill outside the root as a package boundary", async () => diff --git a/packages/provider-cursor/src/server/skills.ts b/packages/provider-cursor/src/server/skills.ts index bc89ee6e35fa3..5ef6888876036 100644 --- a/packages/provider-cursor/src/server/skills.ts +++ b/packages/provider-cursor/src/server/skills.ts @@ -17,6 +17,7 @@ const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); const MAX_SKILL_DEPTH = 10; const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000); const MAX_SKILL_SCAN_ENTRIES = 10_000; +const MAX_SKILL_SCAN_STATS = 100_000; const MAX_SKILL_SCAN_BYTES = ByteSize.bytes(8_000_000); interface CursorSkillFrontmatter { @@ -29,6 +30,7 @@ interface CursorSkillFrontmatter { interface CursorSkillScanBudget { remainingEntries: number; + remainingStats: number; remainingBytes: bigint; exhausted: boolean; incomplete: boolean; @@ -153,6 +155,11 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( resolvedDirectory === rootDirectory || resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); + if (input.budget.remainingStats === 0) { + input.budget.exhausted = true; + return; + } + input.budget.remainingStats -= 1; const skillPath = path.join(directory, "SKILL.md"); const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); if (skillInfo?.type === "File") { @@ -189,17 +196,25 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( return; } for (const entry of [...entries].sort()) { - if (input.budget.remainingEntries === 0) { + // Installed dependencies can exhaust the shared budget and hide unrelated skills. + if (entry === "node_modules") continue; + // Files spend no directory budget, so a separate cap keeps a huge file tree bounded. + if (input.budget.remainingStats === 0) { input.budget.exhausted = true; return; } - input.budget.remainingEntries -= 1; - // Installed dependencies can exhaust the shared budget and hide unrelated skills. - if (entry === "node_modules") continue; + input.budget.remainingStats -= 1; const child = path.join(directory, entry); const info = yield* orUndefined(fileSystem.stat(child), input.budget); + // Only directories can hold skills, so only they spend the budget: a skill + // shipping many ordinary files must not hide its siblings. if (info?.type !== "Directory") continue; if (depth >= MAX_SKILL_DEPTH) continue; + if (input.budget.remainingEntries === 0) { + input.budget.exhausted = true; + return; + } + input.budget.remainingEntries -= 1; yield* visit(child, depth + 1); } }); @@ -233,6 +248,7 @@ const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( const skillsByName = new Map(); const budget: CursorSkillScanBudget = { remainingEntries: MAX_SKILL_SCAN_ENTRIES, + remainingStats: MAX_SKILL_SCAN_STATS, remainingBytes: MAX_SKILL_SCAN_BYTES, exhausted: false, incomplete: false, diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index 1b4cadf88ae3f..617616d14bc25 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -182,17 +182,46 @@ describe("Cursor usage limits", () => { }), ); - it.effect("does not use a stored login for an explicit API key", () => + it.effect("reads an API key's own account instead of a stored login", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request usage")), + for (const platform of ["linux", "darwin"] as const) { + const requests: Array = []; + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "api-key" }, true).pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read a stored login"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push([request.url, request.headers.authorization]); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + request.url.endsWith("/auth/exchange_user_api_key") + ? { accessToken: "api-key-access-token" } + : { planUsage: { totalPercentUsed: 37 } }, + ), + ), + ); + }), + ), ), - ), - ); - expect(limits.unavailable?.reason).toBe("unsupported"); + ); + expect(requests).toEqual([ + ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], + [ + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + "Bearer api-key-access-token", + ], + ]); + expect(limits.windows[0]?.usedPercent).toBe(37); + } }), ); @@ -246,13 +275,14 @@ describe("Cursor usage limits", () => { }), ); - it.effect("does not read Keychain or send its token to a custom endpoint", () => + it.effect("does not send a Keychain login or API key to a custom endpoint", () => Effect.gen(function* () { for (const [apiEndpoint, environment] of [ ["http://localhost:3000", {}], ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], ["https://cursor-proxy.example", {}], ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ["https://cursor-proxy.example", { CURSOR_API_KEY: "api-key" }], ] as const) { const limits = yield* withNodeServices( readCursorUsageLimits({ apiEndpoint }, environment, true).pipe( diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 049e646d2c492..87fcecef4e97f 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -16,10 +16,10 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; +import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; import * as CursorKeychain from "./CursorKeychain.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); -const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); const CursorUsageResponse = Schema.Struct({ billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), @@ -77,19 +77,25 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function DEFAULT_CURSOR_API_ENDPOINT ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); - // An explicit API key can name a different account from the stored login. - if (!token && environment.CURSOR_API_KEY?.trim()) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } + const apiKey = environment.CURSOR_API_KEY?.trim(); const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - if (!token && credentialStore === "memory") { + // An API key can name a different account from the stored login, so read the key's own. + if (!token && apiKey) { + if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor usage for an API key requires the default Cursor endpoint.", + }); + } + token = yield* exchangeCursorApiKey(apiKey); + } else if (!token && credentialStore === "memory") { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported", message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", }); - } - if (!token && platform === "darwin" && credentialStore !== "file") { + } else if (!token && platform === "darwin" && credentialStore !== "file") { if (!allowKeychain) { return makeUnavailableUsageLimits({ checkedAt, diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index fb6dfae56ef86..b77a4431faf1a 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -7,7 +7,7 @@ import { } from "@t3tools/contracts"; import type { FxSettings } from "../settings.ts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import type * as Crypto from "effect/Crypto"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type * as Scope from "effect/Scope"; @@ -219,7 +219,7 @@ export const makeFxAcpRuntime = Effect.fn("makeFxAcpRuntime")(function* ( ): Effect.fn.Return< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope > { const { fxSettings, environment, ...runtimeOptions } = input; const context = yield* Layer.build( diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts index dfb956e6d13f1..868b13f497de3 100644 --- a/packages/provider-fx/src/server/adapter.ts +++ b/packages/provider-fx/src/server/adapter.ts @@ -2,7 +2,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; import { type OrchestrationV2ProviderCapabilities } from "@t3tools/contracts"; import { FxSettings } from "../settings.ts"; -import type * as Crypto from "effect/Crypto"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -159,7 +159,7 @@ export interface FxAdapterV2Options { ) => Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope >; readonly assertComplete?: Effect.Effect; } @@ -203,7 +203,7 @@ export const makeFxAdapterV2 = Effect.fn("makeFxAdapterV2")(function* ( export type FxAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 | Path.Path diff --git a/packages/provider-fx/src/server/driver.ts b/packages/provider-fx/src/server/driver.ts index 3d52e86d789de..2eba58db6ffab 100644 --- a/packages/provider-fx/src/server/driver.ts +++ b/packages/provider-fx/src/server/driver.ts @@ -1,7 +1,7 @@ import { TextGenerationError } from "@t3tools/contracts"; import { FxSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import type * as Crypto from "effect/Crypto"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -66,7 +66,7 @@ const unsupportedTextGeneration: ProviderTextGenerationRuntime = (() => { export type FxDriverEnv = | FxAdapterV2DriverEnv | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | Path.Path | ProviderHost.ProviderHost; diff --git a/packages/provider-grok/src/server/acpSupport.ts b/packages/provider-grok/src/server/acpSupport.ts index be98e177da994..a7d1d5ed3b0cd 100644 --- a/packages/provider-grok/src/server/acpSupport.ts +++ b/packages/provider-grok/src/server/acpSupport.ts @@ -6,7 +6,7 @@ import { type RuntimeMode, } from "@t3tools/contracts"; import type { GrokSettings } from "../settings.ts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -126,7 +126,7 @@ export const makeGrokAcpRuntime = Effect.fn("GrokAcpSupport.makeGrokAcpRuntime") ): Effect.fn.Return< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope > { const processGroupPlatform = yield* HostProcess.Platform.pipe(Effect.provide(NodeServices.layer)); const acpContext = yield* Layer.build( diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index d826315b69fa6..35a8794619dff 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -14,7 +14,7 @@ import { type RuntimeMode, } from "@t3tools/contracts"; import { GrokSettings } from "../settings.ts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -128,7 +128,7 @@ export interface GrokAdapterV2Options { ) => Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope >; readonly assertComplete?: Effect.Effect; } @@ -357,7 +357,7 @@ export const makeGrokAdapterV2 = Effect.fn("makeGrokAdapterV2")(function* ( export type GrokAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 | Path.Path diff --git a/packages/provider-grok/src/server/driver.test.ts b/packages/provider-grok/src/server/driver.test.ts index 3a080ec60582a..5987a27c38ad2 100644 --- a/packages/provider-grok/src/server/driver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; @@ -21,7 +22,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(IdAllocator.layer), Layer.provideMerge(McpProviderSessions.layer), Layer.provideMerge(ProviderLatestVersions.layer), diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index acb8690509e2d..c9f683c3417ee 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -1,6 +1,6 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { GrokSettings } from "../settings.ts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -93,7 +93,7 @@ export type GrokDriverEnv = | GrokAdapterV2DriverEnv | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -117,7 +117,7 @@ export const GrokDriver: ProviderDriver enabled, config, }) { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; @@ -189,7 +189,7 @@ export const GrokDriver: ProviderDriver Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index 3ea0c00ec1a7e..aa58c8babae7d 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -9,7 +9,7 @@ import { import type { GrokSettings } from "../settings.ts"; import type * as EffectAcpSchema from "effect-acp/compat"; import { causeErrorTag } from "@t3tools/shared/observability"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -401,7 +401,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom > { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); diff --git a/packages/provider-grok/src/server/textGeneration.ts b/packages/provider-grok/src/server/textGeneration.ts index b3ead17d24f98..e31594a5c2f97 100644 --- a/packages/provider-grok/src/server/textGeneration.ts +++ b/packages/provider-grok/src/server/textGeneration.ts @@ -1,4 +1,4 @@ -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -27,7 +27,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, ) { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runGrokJson: TextGenerationOperations.Runner = Effect.fn("GrokTextGeneration.runGrokJson")( @@ -41,7 +41,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, }).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner), ); diff --git a/packages/provider-kiro/package.json b/packages/provider-kiro/package.json new file mode 100644 index 0000000000000..b0cdc600f6072 --- /dev/null +++ b/packages/provider-kiro/package.json @@ -0,0 +1,40 @@ +{ + "name": "@t3tools/provider-kiro", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-acp": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "effect-acp": "workspace:*", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-kiro/src/client.ts b/packages/provider-kiro/src/client.ts new file mode 100644 index 0000000000000..06f551688bce6 --- /dev/null +++ b/packages/provider-kiro/src/client.ts @@ -0,0 +1,43 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { KiroSettings } from "./settings.ts"; + +const TILE = + "M260 0H940A260 260 0 0 1 1200 260V940A260 260 0 0 1 940 1200H260A260 260 0 0 1 0 940V260A260 260 0 0 1 260 0Z"; +const GHOST = + "M398.554 818.914C316.315 1001.03 491.477 1046.74 620.672 940.156C658.687 1059.66 801.052 970.473 852.234 877.795C964.787 673.567 919.318 465.357 907.64 422.374C827.637 129.443 427.623 128.946 358.8 423.865C342.651 475.544 342.402 534.18 333.458 595.051C328.986 625.86 325.507 645.488 313.83 677.785C306.873 696.424 297.68 712.819 282.773 740.645C259.915 783.881 269.604 867.113 387.87 823.883L399.051 818.914H398.554Z"; +const LEFT_EYE = + "M636.123 549.353C603.328 549.353 598.359 510.097 598.359 486.742C598.359 465.623 602.086 448.977 609.293 438.293C615.504 428.852 624.697 424.131 636.123 424.131C647.555 424.131 657.492 428.852 664.447 438.541C672.398 449.474 676.623 466.12 676.623 486.742C676.623 525.998 661.471 549.353 636.375 549.353H636.123Z"; +const RIGHT_EYE = + "M771.24 549.353C738.445 549.353 733.477 510.097 733.477 486.742C733.477 465.623 737.203 448.977 744.41 438.293C750.621 428.852 759.814 424.131 771.24 424.131C782.672 424.131 792.609 428.852 799.564 438.541C807.516 449.474 811.74 466.12 811.74 486.742C811.74 525.998 796.588 549.353 771.492 549.353H771.24Z"; +const white = { light: "#FFFFFF", dark: "#FFFFFF" }; +const black = { light: "#000000", dark: "#000000" }; + +export const kiroClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("kiro"), + label: "Kiro", + featured: false, + badgeLabel: "Early Access", + settingsSchema: KiroSettings, + environmentFields: [ + { + name: "KIRO_API_KEY", + label: "Kiro API key", + description: "Optional. Signs in without `kiro-cli login` (Kiro Pro and above).", + placeholder: "Paste API key", + sensitive: true, + }, + ], + icon: { + viewBox: "0 0 1200 1200", + fill: { light: "#9046FF", dark: "#9046FF" }, + paths: [ + // The ghost is a hole in the tile, so a monochrome icon still shows it. + { d: `${TILE}${GHOST}${LEFT_EYE}${RIGHT_EYE}`, fillRule: "evenodd" }, + { d: GHOST, fill: white }, + { d: LEFT_EYE, fill: black }, + { d: RIGHT_EYE, fill: black }, + ], + }, +}); diff --git a/packages/provider-kiro/src/server.ts b/packages/provider-kiro/src/server.ts new file mode 100644 index 0000000000000..e0dce55de5d68 --- /dev/null +++ b/packages/provider-kiro/src/server.ts @@ -0,0 +1,2 @@ +export { KiroDriver, type KiroDriverEnv } from "./server/driver.ts"; +export { type KiroAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/packages/provider-kiro/src/server/acpSupport.ts b/packages/provider-kiro/src/server/acpSupport.ts new file mode 100644 index 0000000000000..43d8c06b77a98 --- /dev/null +++ b/packages/provider-kiro/src/server/acpSupport.ts @@ -0,0 +1,151 @@ +import type * as EffectAcpSchema from "effect-acp/compat"; +import type { ProviderApprovalOption, RuntimeMode } from "@t3tools/contracts"; +import type { KiroSettings } from "../settings.ts"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { + acpPermissionDisposition, + type AcpPermissionDisposition, + type AcpRuntimePolicy, +} from "@t3tools/provider-acp/server/clientPolicy"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; + +/** + * Kiro's default engine is a v2 server this driver does not speak, so V3 is + * requested. `--auth-method=cli` keeps access tokens inside Kiro. V3 rejects the + * v2 launch flags; mode and model are set per session with `session/set_config_option`. + */ +const KIRO_ACP_ARGS = ["acp", "--agent-engine=v3", "--auth-method=cli"] as const; + +/** + * Kiro's `autopilot` session option. Off (Supervised), Kiro also asks the user + * to review a turn's file changes before it ends. Sessions start "on", so T3 + * sets it from the thread's runtime mode. + */ +export const KIRO_AUTOPILOT_CONFIG_ID = "autopilot"; + +/** Kiro's model session option (`configId: "model"` in the V3 migration guide). */ +export const KIRO_MODEL_CONFIG_ID = "model"; + +/** + * Kiro's reasoning effort session option (`configId: "effortLevel"` in the V3 + * migration guide). Kiro advertises it only while a model with effort runs. + */ +export const KIRO_EFFORT_CONFIG_ID = "effortLevel"; + +/** + * Kiro's agent session option. Kiro V3 offers its agents (the bundled ones, + * `~/.kiro/agents`, and the workspace's `.kiro/agents`) as the choices of its + * `mode` select, and the thread's choice is stored under the same id. + */ +export const KIRO_AGENT_CONFIG_ID = "mode"; + +/** + * Kiro offers two permission postures, Supervised and Autopilot, which T3's + * Supervised and Full access select. Any other mode runs Supervised. + */ +export const KIRO_SUPPORTED_RUNTIME_MODES = [ + "approval-required", + "full-access", +] as const satisfies ReadonlyArray; + +/** + * Autopilot is on only for an unrestricted Full access thread. An explicit + * approval or sandbox override keeps Kiro Supervised, so its end-of-turn + * review still reaches the user and every tool prompt reaches T3's policy + * check (the same rule as Grok's `grokLaunchRuntimeMode`). + */ +export function kiroAutopilotValue(policy: { + readonly runtimeMode: RuntimeMode; + readonly approvalPolicy?: unknown; + readonly sandboxPolicy?: unknown; +}): "on" | "off" { + return policy.runtimeMode === "full-access" && + policy.approvalPolicy === undefined && + policy.sandboxPolicy === undefined + ? "on" + : "off"; +} + +type KiroAcpRuntimeSettings = Pick; + +export function buildKiroAcpSpawnInput( + settings: KiroAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: settings?.binaryPath || "kiro-cli", + args: [...KIRO_ACP_ARGS], + cwd, + ...(environment === undefined ? {} : { env: environment }), + }; +} + +/** + * Approval choices a Kiro permission request can honor; V3's options are + * authoritative. Its `allow_always` saves a consent rule for the workspace or + * wider, so it is offered, as "Always allow", only without a one-time choice. + */ +export function kiroApprovalOptions( + request: EffectAcpSchema.RequestPermissionRequest, +): ReadonlyArray { + const has = (kind: EffectAcpSchema.PermissionOption["kind"]) => + request.options.some((option) => option.kind === kind && option.optionId.trim().length > 0); + return [ + ...(has("allow_once") + ? [{ decision: "accept", label: "Allow once" } as const] + : has("allow_always") + ? [{ decision: "acceptForSession", label: "Always allow" } as const] + : []), + ...(has("reject_once") ? [{ decision: "decline", label: "Deny" } as const] : []), + { decision: "cancel", label: "Cancel" }, + ]; +} + +/** + * T3's answer to a Kiro permission request. A policy approval may only use + * Kiro's `allow_once`: its `allow_always` saves a consent rule for the whole + * workspace or wider, which would outlive the thread. When Kiro offers no + * one-time choice, the request goes to the user instead of persisting a grant. + */ +export function kiroPermissionDisposition( + policy: AcpRuntimePolicy, + request: EffectAcpSchema.RequestPermissionRequest, +): AcpPermissionDisposition { + const disposition = acpPermissionDisposition(policy, request); + const offersOnce = request.options.some( + (option) => option.kind === "allow_once" && option.optionId.trim().length > 0, + ); + return disposition === "allow" && !offersOnce ? "ask" : disposition; +} + +export interface KiroAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "spawn" +> { + readonly settings: KiroAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export const makeKiroAcpRuntime = Effect.fn("KiroAcpSupport.makeKiroAcpRuntime")(function* ( + input: KiroAcpRuntimeInput, +): Effect.fn.Return< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope +> { + const { settings, environment, ...runtimeOptions } = input; + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...runtimeOptions, + spawn: buildKiroAcpSpawnInput(settings, input.cwd, environment), + }), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); +}); diff --git a/packages/provider-kiro/src/server/adapter.ts b/packages/provider-kiro/src/server/adapter.ts new file mode 100644 index 0000000000000..6815b60e8f227 --- /dev/null +++ b/packages/provider-kiro/src/server/adapter.ts @@ -0,0 +1,641 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { + type ModelSelection, + ProviderDriverKind, + type OrchestrationV2ProviderCapabilities, +} from "@t3tools/contracts"; +import { KiroSettings } from "../settings.ts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type * as EffectAcpSchema from "effect-acp/compat"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2Flavor, + type AcpAdapterV2RuntimeInput, + type AcpAdapterV2SubagentUpdate, +} from "@t3tools/provider-acp/server/adapter"; +import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "@t3tools/provider-core/server/adapterDriver"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import type * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import type * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { + KIRO_AGENT_CONFIG_ID, + KIRO_AUTOPILOT_CONFIG_ID, + KIRO_EFFORT_CONFIG_ID, + KIRO_MODEL_CONFIG_ID, + kiroApprovalOptions, + kiroAutopilotValue, + kiroPermissionDisposition, + makeKiroAcpRuntime, +} from "./acpSupport.ts"; + +export const KIRO_PROVIDER = ProviderDriverKind.make("kiro"); +/** T3's slug for "Kiro default", and Kiro's documented default model id. */ +const KIRO_DEFAULT_MODEL = "default"; +const KIRO_COMPACT_METHOD = "_kiro/session/compact"; +const KIRO_STEER_METHOD = "_session/steer"; +const KIRO_FALLBACK_DEFAULT_MODEL = "auto"; +const DEFAULT_KIRO_SETTINGS = Schema.decodeSync(KiroSettings)({}); + +const KiroProviderCapabilitiesV2 = { + ...AcpProviderCapabilitiesV2, + sessions: { + ...AcpProviderCapabilitiesV2.sessions, + supportsModelSwitchInSession: true, + }, + // Kiro 2.27 and later fork at a turn's prompt (`_meta.kiro.messageId`); see kiroForkFromTurn. + threads: { + ...AcpProviderCapabilitiesV2.threads, + canForkThread: true, + canForkFromTurn: true, + }, + // `_session/steer` waits for the running tool, then Kiro reads the message at its next step. + turns: { + ...AcpProviderCapabilitiesV2.turns, + supportsActiveSteering: true, + activeSteeringInterruptsTools: false, + // `_session/steer` takes a string, so a message with images restarts the turn. + activeSteeringAcceptsAttachments: false, + }, + tools: { + ...AcpProviderCapabilitiesV2.tools, + supportsMcpTools: true, + }, + subagents: { + ...AcpProviderCapabilitiesV2.subagents, + supportsSubagents: true, + emitsSubagentLifecycle: true, + }, +} satisfies OrchestrationV2ProviderCapabilities; + +const KiroCompactResult = Schema.Struct({ success: Schema.Boolean }); +const decodeKiroCompactResult = Schema.decodeUnknownOption(KiroCompactResult); + +/** Kiro answers a `/compact` prompt as chat, so T3 compacts with Kiro's own request. */ +const compactKiroSession = ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, +) => + runtime.request(KIRO_COMPACT_METHOD, { sessionId }).pipe( + Effect.flatMap((result) => + Option.exists(decodeKiroCompactResult(result), (decoded) => decoded.success) + ? Effect.void + : Effect.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "Kiro could not compact the conversation.", + method: KIRO_COMPACT_METHOD, + data: result, + }), + ), + ), + ); + +const KiroSteerResult = Schema.Struct({ queued: Schema.Literal(true), messageId: Schema.String }); +const decodeKiroSteerResult = Schema.decodeUnknownOption(KiroSteerResult); +const KiroSteeringInjected = Schema.Struct({ + kiro: Schema.Struct({ kind: Schema.Literal("steering_injected"), messageId: Schema.String }), +}); +const decodeKiroSteeringInjected = Schema.decodeUnknownOption(KiroSteeringInjected); + +/** Kiro queues a steer, then reports `steering_injected` with its id once the model reads it. */ +const kiroSteering = { + send: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + text: string, + ) => + runtime.request(KIRO_STEER_METHOD, { sessionId, message: text }).pipe( + Effect.flatMap((result) => + Option.match(decodeKiroSteerResult(result), { + onSome: (decoded) => Effect.succeed(decoded.messageId), + onNone: () => + Effect.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "Kiro did not queue the steering message.", + method: KIRO_STEER_METHOD, + data: result, + }), + ), + }), + ), + ), + injectedSteerId: (notification: EffectAcpSchema.SessionNotification) => + notification.update.sessionUpdate === "session_info_update" + ? Option.getOrUndefined(decodeKiroSteeringInjected(notification.update._meta))?.kiro.messageId + : undefined, +}; + +const decodeKiroForkCapability = Schema.decodeUnknownOption( + Schema.Struct({ kiro: Schema.Struct({ messageId: Schema.Literal(true) }) }), +); +const decodeKiroUserMessageId = Schema.decodeUnknownOption( + Schema.Struct({ + kiro: Schema.Struct({ + kind: Schema.Literal("user_message_id_assigned"), + userMessageId: Schema.String, + }), + }), +); + +/** + * Kiro assigns each prompt a `userMessageId`, and `session/fork` with that id keeps the + * session up to and including the reply to that prompt. + */ +const kiroForkFromTurn = { + supported: ( + initializeResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["initializeResult"], + ) => + Option.isSome( + decodeKiroForkCapability( + initializeResult.agentCapabilities?.sessionCapabilities?.fork?._meta, + ), + ), + nativeTurnId: (notification: EffectAcpSchema.SessionNotification) => + notification.update.sessionUpdate === "session_info_update" + ? Option.getOrUndefined(decodeKiroUserMessageId(notification.update._meta))?.kiro + .userMessageId + : undefined, + forkMeta: (messageId: string) => ({ kiro: { messageId } }), +}; + +export interface KiroAdapterV2Options { + readonly instanceId: Parameters[0]["instanceId"]; + readonly settings: KiroSettings; + readonly environment: NodeJS.ProcessEnv; + readonly selfInvocation: SelfInvocation; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; + /** + * Kiro's id for "Kiro default" (the provider snapshot's `--list-models` + * default). Absent, it is `auto`. + */ + readonly defaultModel?: Effect.Effect; + /** Replaces the `kiro-cli` launch (replay tests). Kiro's session setup still applies. */ + readonly makeRuntime?: ( + input: AcpAdapterV2RuntimeInput, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope + >; + readonly assertComplete?: Effect.Effect; + readonly testHooks?: Parameters[0]["testHooks"]; +} + +/** Kiro's `model` option once it advertises one. */ +const findKiroModelOption = (configOptions: ReadonlyArray) => + configOptions.find((option) => option.id === KIRO_MODEL_CONFIG_ID || option.category === "model"); + +/** + * Kiro selects models only through its `model` config option; "default" is + * Kiro's own default. The write goes to `model` even before Kiro advertises it, + * and for unlisted ids: Kiro fails the next prompt on a model it can't use. + */ +const applyKiroModel = Effect.fn("KiroAdapterV2.applyKiroModel")(function* ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + modelSelection: ModelSelection, + defaultModel: Effect.Effect, + agentSwitched: boolean, +) { + const modelOption = findKiroModelOption(yield* runtime.getConfigOptions); + const current = modelOption?.type === "select" ? modelOption.currentValue : undefined; + const selected = modelSelection.model.trim(); + const isDefault = selected.length === 0 || selected === KIRO_DEFAULT_MODEL; + // Before Kiro advertises `model` the session is new, so it already runs + // Kiro's default, unless an agent switch just moved it to the agent's model. + if (isDefault && modelOption?.type !== "select" && !agentSwitched) { + return current; + } + const requested = isDefault ? yield* defaultModel : selected; + if (requested === current) { + return current; + } + yield* runtime.setConfigOption(modelOption?.id ?? KIRO_MODEL_CONFIG_ID, requested, { + allowUnlistedValue: true, + }); + return requested; +}); + +/** + * Bound on waiting for Kiro to advertise the model T3 just wrote. Kiro 2.27.0 + * sent it 40-90 ms after the write's result. + */ +const KIRO_MODEL_ADVERT_BOUND = "2 seconds"; + +/** + * Writes the thread's effort to Kiro's `effortLevel` once Kiro advertises the + * written model, since Kiro ignores the option before then and resets it on a + * model change. Levels the model doesn't offer are not sent. A rejected write + * or no advert within the bound keeps Kiro's level rather than failing the turn. + */ +const applyKiroEffort = Effect.fn("KiroAdapterV2.applyKiroEffort")(function* ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + modelSelection: ModelSelection, + appliedModel: string | undefined, +) { + const requested = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"); + if (requested === undefined) return; + const advertised = yield* runtime.configOptionChanges.pipe( + Stream.filter((configOptions) => { + const modelOption = findKiroModelOption(configOptions); + return ( + appliedModel === undefined || + (modelOption?.type === "select" && modelOption.currentValue === appliedModel) + ); + }), + Stream.runHead, + Effect.timeoutOption(KIRO_MODEL_ADVERT_BOUND), + Effect.map(Option.flatten), + ); + if (Option.isNone(advertised)) { + return yield* Effect.logWarning("Kiro did not advertise the model's reasoning effort", { + waitedForWrittenModel: appliedModel !== undefined, + }); + } + const effortOption = advertised.value.find((option) => option.id === KIRO_EFFORT_CONFIG_ID); + if (effortOption?.type !== "select" || !selectValues(effortOption).includes(requested)) { + return; + } + yield* runtime.setConfigOption(KIRO_EFFORT_CONFIG_ID, requested).pipe( + Effect.catchTags({ + // `requested` is one of the levels Kiro just advertised, so it is safe to log. + AcpRequestError: (error) => + Effect.logWarning("Kiro rejected the reasoning effort", { + value: requested, + code: error.code, + }), + }), + ); +}); + +/** + * Selects the thread's Kiro agent and reports whether a switch went out. It runs + * before the model write, because switching applies the agent's own model. An + * agent the session doesn't list is skipped: Kiro would accept it and not switch. + */ +const applyKiroAgent = Effect.fn("KiroAdapterV2.applyKiroAgent")(function* ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + modelSelection: ModelSelection, +) { + const requested = getModelSelectionStringOptionValue(modelSelection, KIRO_AGENT_CONFIG_ID); + if (requested === undefined) return false; + const agentOption = (yield* runtime.getConfigOptions).find( + (option) => option.id === KIRO_AGENT_CONFIG_ID, + ); + if (agentOption?.type !== "select" || agentOption.currentValue === requested) return false; + if (!selectValues(agentOption).includes(requested)) { + yield* Effect.logWarning("Kiro does not list the thread's agent", { + listedAgents: selectValues(agentOption).length, + }); + return false; + } + yield* runtime.setConfigOption(KIRO_AGENT_CONFIG_ID, requested); + return true; +}); + +const applyKiroModelSelection = + (defaultModel: Effect.Effect): NonNullable => + ({ runtime, modelSelection }) => + applyKiroAgent(runtime, modelSelection).pipe( + Effect.flatMap((agentSwitched) => + applyKiroModel(runtime, modelSelection, defaultModel, agentSwitched), + ), + Effect.tap((appliedModel) => applyKiroEffort(runtime, modelSelection, appliedModel)), + ); + +function selectValues( + option: Extract, +): ReadonlyArray { + return option.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), + ); +} + +const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); + +/** + * Evidence that Kiro started the current prompt, so Stop's `session/cancel` is + * honored: a `turn_start` info update or output only a running prompt produces. + * Kiro doesn't promise `turn_start`, so Stop gives up after KIRO_CANCEL_HOLD_BOUND. + */ +function isKiroPromptActivity(update: EffectAcpSchema.SessionUpdate): boolean { + switch (update.sessionUpdate) { + case "agent_message_chunk": + case "agent_message": + case "agent_thought_chunk": + case "agent_thought": + case "tool_call": + case "tool_call_update": + case "tool_call_content_chunk": + case "plan_update": + return true; + case "session_info_update": { + const kiro = update._meta?.kiro; + return ( + typeof kiro === "object" && kiro !== null && "kind" in kiro && kiro.kind === "turn_start" + ); + } + default: + return false; + } +} + +/** Past the 0-0.5 s window where TinBane saw cancels dropped, and the 1.5 s cancel they saw honored. */ +const KIRO_CANCEL_HOLD_BOUND = "2 seconds"; + +/** + * Kiro 2.31 fetches an Identity Center account's cloud config as a tool call + * of its own around session start (reported by @hstern), which can land inside + * the user's turn. Only the call carries this id; its updates name the call. + */ +const KIRO_CLOUD_CONFIG_TOOL_ID = "fetch_cloud_config"; + +/** Drops Kiro's cloud-config tool call and every update that follows it. */ +function makeKiroCloudConfigFilter() { + const running = new Set(); + return (notification: EffectAcpSchema.SessionNotification): boolean => { + const { update } = notification; + if ( + update.sessionUpdate !== "tool_call" && + update.sessionUpdate !== "tool_call_update" && + update.sessionUpdate !== "tool_call_content_chunk" + ) { + return false; + } + const key = `${notification.sessionId}\n${update.toolCallId}`; + const kiro = update._meta?.kiro; + const toolId = + typeof kiro === "object" && kiro !== null && "toolId" in kiro ? kiro.toolId : null; + if (toolId === KIRO_CLOUD_CONFIG_TOOL_ID) running.add(key); + else if (!running.has(key)) return false; + if ("status" in update && (update.status === "completed" || update.status === "failed")) { + running.delete(key); + } + return true; + }; +} + +const KIRO_STEERING_NOTE = "[STEERING steer-"; +const decodeKiroSteeringInfo = Schema.decodeUnknownOption( + Schema.Struct({ kiro: Schema.Struct({ kind: Schema.String }) }), +); + +/** Length of the longest end of `text` that could still grow into the steering note. */ +function steeringNotePrefixLength(text: string): number { + for (let length = Math.min(text.length, KIRO_STEERING_NOTE.length - 1); length > 0; length -= 1) { + if (KIRO_STEERING_NOTE.startsWith(text.slice(-length))) return length; + } + return 0; +} + +/** + * After an injected steer, Kiro's model ends its reply with a `[STEERING steer-…: …]` + * note meant for Kiro's own UI. This drops it from the streamed text, holding back a + * chunk end that may be the start of the note until the next chunk shows otherwise. + */ +function makeKiroSteeringNoteFilter() { + const sessions = new Map(); + return ( + notification: EffectAcpSchema.SessionNotification, + ): EffectAcpSchema.SessionNotification => { + const { update } = notification; + if (update.sessionUpdate === "session_info_update") { + const kind = Option.getOrUndefined(decodeKiroSteeringInfo(update._meta))?.kiro.kind; + if (kind === "steering_injected") { + sessions.set(notification.sessionId, { held: "", inNote: false }); + } else if (kind === "steering_cleared") sessions.delete(notification.sessionId); + return notification; + } + const state = sessions.get(notification.sessionId); + if ( + state === undefined || + update.sessionUpdate !== "agent_message_chunk" || + update.content.type !== "text" + ) { + return notification; + } + let pending = state.held + update.content.text; + let visible = ""; + state.held = ""; + while (pending.length > 0) { + if (state.inNote) { + const end = pending.indexOf("]"); + if (end < 0) break; + pending = pending.slice(end + 1); + state.inNote = false; + continue; + } + const start = pending.indexOf(KIRO_STEERING_NOTE); + if (start >= 0) { + visible += pending.slice(0, start).trimEnd(); + pending = pending.slice(start + KIRO_STEERING_NOTE.length); + state.inNote = true; + continue; + } + const held = steeringNotePrefixLength(pending); + visible += pending.slice(0, pending.length - held); + state.held = pending.slice(pending.length - held); + break; + } + return { + ...notification, + update: { ...update, content: { ...update.content, text: visible } }, + }; + }; +} + +const decodeKiroAgentSubtask = Schema.decodeUnknownOption( + Schema.Struct({ + kiro: Schema.Struct({ agentSubtaskId: Schema.String, kind: Schema.optionalKey(Schema.String) }), + }), +); +const decodeKiroSubagentInput = Schema.decodeUnknownOption( + Schema.Struct({ name: Schema.String, prompt: Schema.String }), +); + +/** + * Kiro runs sub-agents inside the parent's session: frames from an + * `invoke_sub_agent` call carry `_meta.kiro.agentSubtaskId`, and routing them + * to that id as their session gives the shared adapter the child to project. + */ +function normalizeKiroSessionUpdate( + notification: EffectAcpSchema.SessionNotification, +): EffectAcpSchema.SessionNotification { + const { update } = notification; + const subtask = decodeKiroAgentSubtask("_meta" in update ? update._meta : undefined); + return Option.isSome(subtask) && subtask.value.kiro.kind !== "agent-subtask" + ? { ...notification, sessionId: subtask.value.kiro.agentSubtaskId } + : notification; +} + +/** The `invoke_sub_agent` call; it completes with the sub-agent's final response. */ +function extractKiroSubagentUpdate(tool: AcpToolCallState): AcpAdapterV2SubagentUpdate | undefined { + const subtask = decodeKiroAgentSubtask(tool.data.meta); + if (Option.isNone(subtask) || subtask.value.kiro.kind !== "agent-subtask") return undefined; + const input = decodeKiroSubagentInput(tool.data.rawInput); + const status = + tool.status === "failed" ? "failed" : tool.status === "completed" ? "completed" : "running"; + return { + nativeTaskId: tool.toolCallId, + childSessionId: subtask.value.kiro.agentSubtaskId, + prompt: Option.isSome(input) ? input.value.prompt : (tool.title ?? "Kiro sub-agent"), + title: Option.isSome(input) ? input.value.name : null, + model: null, + status, + result: typeof tool.data.rawOutput === "string" ? tool.data.rawOutput : null, + }; +} + +/** + * Kiro's prompt errors carry a message meant for the user (live 2.27: -32000 + * "The model 'x' is not available. Please select a different model and try + * again. (Request ID: …)"). Show it instead of the generic failure text; + * makeProviderFailure bounds and redacts it. + */ +function kiroPromptFailure(cause: unknown) { + return makeProviderFailure({ + cause, + ...(isAcpRequestError(cause) ? { message: cause.errorMessage, code: String(cause.code) } : {}), + class: "provider_error", + }); +} + +function makeKiroAcpAdapterFlavor(options: KiroAdapterV2Options): AcpAdapterV2Flavor { + const makeRuntime = + options.makeRuntime ?? + ((input: AcpAdapterV2RuntimeInput) => { + const { runtimePolicy: _runtimePolicy, processEnvironment, ...runtimeInput } = input; + return makeKiroAcpRuntime({ + ...runtimeInput, + settings: options.settings, + environment: { ...options.environment, ...processEnvironment }, + }); + }); + const stripSteeringNote = makeKiroSteeringNoteFilter(); + return { + driver: KIRO_PROVIDER, + runtimeHarness: "Kiro", + capabilities: KiroProviderCapabilitiesV2, + // Kiro V3 loads the user's hooks only when the client opts in. `v2` makes + // Kiro run them itself; `enabled` alone hands them to the client as + // `_kiro/hooks/*` requests, which T3 does not answer. + clientCapabilitiesMeta: { kiro: { hooks: { enabled: true, v2: true } } }, + makeRuntime, + promptFailure: kiroPromptFailure, + ignoreSessionUpdate: makeKiroCloudConfigFilter(), + normalizeSessionUpdate: (notification) => + stripSteeringNote(normalizeKiroSessionUpdate(notification)), + extractSubagentUpdate: extractKiroSubagentUpdate, + // See applyKiroModelSelection: `model` arrives after session/new. + modelOptionArrivesLate: true, + applyModelSelection: applyKiroModelSelection( + options.defaultModel ?? Effect.succeed(KIRO_FALLBACK_DEFAULT_MODEL), + ), + // The generic option loop runs after the model write, and an agent write + // there would put the agent's own model back. + ownedModelOptionIds: ["reasoningEffort", KIRO_AGENT_CONFIG_ID], + // With Autopilot off (Supervised) Kiro asks the user to accept a turn's + // changes before it ends. T3's runtime policy answers its per-tool prompts + // either way, and a runtime-mode change reopens the session to reapply it. + sessionConfigForPolicy: (policy) => [ + { id: KIRO_AUTOPILOT_CONFIG_ID, value: kiroAutopilotValue(policy) }, + ], + // See isKiroPromptActivity: Kiro 2.27.1 can drop a cancel sent just after + // `session/prompt` and run the turn to completion. + cancelAfterPromptStarts: { isStart: isKiroPromptActivity, bound: KIRO_CANCEL_HOLD_BOUND }, + permissionDisposition: kiroPermissionDisposition, + approvalOptions: kiroApprovalOptions, + compactSession: compactKiroSession, + steering: kiroSteering, + forkFromTurn: kiroForkFromTurn, + // Kiro V3 advertises `promptCapabilities.image`; the shared adapter reads it. + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), + }; +} + +export const makeKiroAdapterV2 = Effect.fn("makeKiroAdapterV2")(function* ( + options: KiroAdapterV2Options, +) { + return yield* makeAcpAdapterV2({ + instanceId: options.instanceId, + flavor: makeKiroAcpAdapterFlavor(options), + selfInvocation: options.selfInvocation, + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + ...(options.testHooks === undefined ? {} : { testHooks: options.testHooks }), + }); +}); + +export type KiroAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom + | FileSystem.FileSystem + | IdAllocator.IdAllocatorV2 + | Path.Path + | McpProviderSessions.McpProviderSessions + | ProviderEventLoggers.ProviderEventLoggers + | ProviderHost.ProviderHost; + +/** `defaultModel` reads Kiro's id for "Kiro default" from the provider snapshot. */ +export const makeKiroAdapterV2Driver = ( + defaultModel: Effect.Effect, +): ProviderAdapterDriver => ({ + driverKind: KIRO_PROVIDER, + configSchema: KiroSettings, + defaultConfig: (): KiroSettings => DEFAULT_KIRO_SETTINGS, + create: Effect.fn("KiroAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcess.Environment; + const selfInvocation = yield* resolveSelfInvocation(); + const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return yield* makeKiroAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + selfInvocation, + defaultModel, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: KIRO_PROVIDER, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: KIRO_PROVIDER, + instanceId: input.instanceId, + detail: "Failed to create Kiro ACP adapter.", + cause, + }), + ), + ), + ), +}); diff --git a/packages/provider-kiro/src/server/driver.test.ts b/packages/provider-kiro/src/server/driver.test.ts new file mode 100644 index 0000000000000..0df02740941d5 --- /dev/null +++ b/packages/provider-kiro/src/server/driver.test.ts @@ -0,0 +1,66 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { KiroSettings } from "../settings.ts"; +import { buildKiroAcpSpawnInput } from "./acpSupport.ts"; +import { kiroAuthFromWhoami } from "./status.ts"; + +const decodeSettings = Schema.decodeUnknownSync(KiroSettings); + +describe("KiroDriver", () => { + it("launches the V3 ACP engine with CLI-owned auth from the configured binary", () => { + assert.deepEqual(buildKiroAcpSpawnInput(decodeSettings({}), "/work"), { + command: "kiro-cli", + args: ["acp", "--agent-engine=v3", "--auth-method=cli"], + cwd: "/work", + }); + const custom = buildKiroAcpSpawnInput( + decodeSettings({ binaryPath: "/opt/kiro/bin/kiro-cli" }), + "/work", + { KIRO_API_KEY: "ksk_test" }, + ); + assert.equal(custom.command, "/opt/kiro/bin/kiro-cli"); + assert.deepEqual(custom.args, ["acp", "--agent-engine=v3", "--auth-method=cli"]); + assert.deepEqual(custom.env, { KIRO_API_KEY: "ksk_test" }); + }); + + // Outputs recorded from `kiro-cli whoami --format json` on Kiro CLI 2.27.0. + it("reads the sign-in state from kiro-cli whoami", () => { + assert.deepEqual(kiroAuthFromWhoami({ code: 1, stdout: '{"account":null}\n' }, {}), { + status: "unauthenticated", + }); + assert.deepEqual( + kiroAuthFromWhoami({ code: 0, stdout: '{"accountType":"ApiKey","email":null}\n' }, {}), + { status: "authenticated", type: "api_key", label: "Kiro API key" }, + ); + assert.deepEqual( + kiroAuthFromWhoami( + { code: 0, stdout: '{"accountType":"BuilderId","email":"dev@example.com"}\n' }, + {}, + ), + { status: "authenticated", label: "Kiro account", email: "dev@example.com" }, + ); + assert.deepEqual(kiroAuthFromWhoami({ code: 2, stdout: "error: service error" }, {}), { + status: "unknown", + }); + assert.deepEqual(kiroAuthFromWhoami(undefined, {}), { status: "unknown" }); + }); + + // Reported from Kiro CLI 2.27.1 with an IAM Identity Center login (exit 0; + // values are placeholders, layout unchanged). + it("keeps the account email when whoami appends an Identity Center profile", () => { + const stdout = [ + '{"accountType":"IamIdentityCenter","email":"jane.doe@example.com","region":"us-east-1","startUrl":"https://d-0123456789.awsapps.com/start"}', + "", + "Profile:", + "KiroProfile-us-east-1", + "arn:aws:codewhisperer:us-east-1:111122223333:profile/EXAMPLE12345", + "", + ].join("\n"); + assert.deepEqual(kiroAuthFromWhoami({ code: 0, stdout }, {}), { + status: "authenticated", + label: "Kiro account", + email: "jane.doe@example.com", + }); + }); +}); diff --git a/packages/provider-kiro/src/server/driver.ts b/packages/provider-kiro/src/server/driver.ts new file mode 100644 index 0000000000000..2603438233824 --- /dev/null +++ b/packages/provider-kiro/src/server/driver.ts @@ -0,0 +1,157 @@ +import { ProviderDriverKind, TextGenerationError } from "@t3tools/contracts"; +import { KiroSettings } from "../settings.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import type { ProviderTextGenerationRuntime } from "@t3tools/provider-core/server/textGeneration"; +import { type KiroAdapterV2DriverEnv, makeKiroAdapterV2Driver } from "./adapter.ts"; +import { + buildInitialKiroProviderSnapshot, + checkKiroProviderStatus, + kiroDefaultModelId, +} from "./status.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("kiro"); +const decodeKiroSettings = Schema.decodeSync(KiroSettings); +// Kiro updates itself in the background (`kiro-cli update` reports only), so +// T3 offers no one-click update. +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +const unsupportedTextGeneration: ProviderTextGenerationRuntime = (() => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Kiro does not provide application text generation.", + }), + ); + return { + supportedOperations: [], + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + generateReviewGuide: () => unsupported("generateReviewGuide"), + generateKnowledgeCandidates: () => unsupported("generateKnowledgeCandidates"), + generateConversationSummary: () => unsupported("generateConversationSummary"), + }; +})(); + +export type KiroDriverEnv = + | KiroAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom + | ProviderHost.ProviderHost; + +export const KiroDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Kiro", + supportsMultipleInstances: true, + }, + configSchema: KiroSettings, + defaultConfig: (): KiroSettings => decodeKiroSettings({}), + create: Effect.fn("KiroDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* SecureRandom.SecureRandom; + const processEnv = yield* mergeProviderInstanceEnvironment( + environment, + yield* HostProcess.Environment, + ); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies KiroSettings; + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialKiroProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider: checkKiroProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(SecureRandom.SecureRandom, crypto), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + // The cause keeps the failure; the detail reaches the snapshot, so it stays fixed. + detail: "Failed to build Kiro snapshot.", + cause, + }), + ), + ); + + // "Kiro default" selects the default the snapshot read from Kiro's model list. + const orchestrationAdapter = yield* makeKiroAdapterV2Driver( + snapshot.getSnapshot.pipe(Effect.map((current) => kiroDefaultModelId(current.models))), + ) + .create({ instanceId, displayName, accentColor, environment, enabled, config }) + .pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Kiro orchestration adapter.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: unsupportedTextGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-kiro/src/server/status.ts b/packages/provider-kiro/src/server/status.ts new file mode 100644 index 0000000000000..39eed259b4be9 --- /dev/null +++ b/packages/provider-kiro/src/server/status.ts @@ -0,0 +1,458 @@ +import { + type ModelCapabilities, + ProviderDriverKind, + type ProviderOptionDescriptor, + type ServerProviderAuth, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type { KiroSettings } from "../settings.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { ChildProcess, type ChildProcessSpawner } from "effect/process"; + +import { + KIRO_AGENT_CONFIG_ID, + KIRO_SUPPORTED_RUNTIME_MODES, + makeKiroAcpRuntime, +} from "./acpSupport.ts"; +import { + AUTH_PROBE_TIMEOUT_MS, + buildServerProvider, + COMPACT_SLASH_COMMAND, + DEFAULT_TIMEOUT_MS, + isCommandMissingCause, + parseGenericCliVersion, + buildSelectOptionDescriptor, + type ProviderProbeResult, + providerModelsFromSettings, + type ServerProviderDraft, + spawnAndCollect, +} from "@t3tools/provider-core/server/snapshotProbe"; + +const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); +const KIRO_PRESENTATION = { + displayName: "Kiro", + showInteractionModeToggle: false, + supportedRuntimeModes: KIRO_SUPPORTED_RUNTIME_MODES, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const KIRO_DEFAULT_MODEL_SLUG = "default"; +const KIRO_FALLBACK_DEFAULT_MODEL = "auto"; +const KIRO_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: KIRO_DEFAULT_MODEL_SLUG, + name: "Kiro default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; +const KIRO_API_KEY_ENV = "KIRO_API_KEY"; + +/** + * The agent picker is not a model trait, but the composer reads options per + * model, so every model, custom ones included, carries it. + */ +const kiroModels = ( + settings: KiroSettings, + listed: ReadonlyArray = KIRO_BUILT_IN_MODELS, + agents?: ProviderOptionDescriptor, +) => + providerModelsFromSettings(listed, settings.customModels, EMPTY_CAPABILITIES).map((model) => { + const descriptors = model.capabilities?.optionDescriptors ?? []; + return agents === undefined || descriptors.some((descriptor) => descriptor.id === agents.id) + ? model + : { + ...model, + capabilities: createModelCapabilities({ optionDescriptors: [...descriptors, agents] }), + }; + }); + +const snapshot = ( + settings: KiroSettings, + checkedAt: string, + probe: ProviderProbeResult, + listed?: ReadonlyArray, + agents?: ProviderOptionDescriptor, +) => + buildServerProvider({ + // Gives the snapshot its version advisory; Kiro runs no enrichment pass. + driver: KIRO_DRIVER_KIND, + presentation: KIRO_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: kiroModels(settings, listed, agents), + // `/compact` runs `_kiro/session/compact`; see compactKiroSession. + slashCommands: [COMPACT_SLASH_COMMAND], + probe, + }); + +export const buildInitialKiroProviderSnapshot = Effect.fn( + "KiroProvider.buildInitialKiroProviderSnapshot", +)(function* (settings: KiroSettings): Effect.fn.Return { + const now = yield* DateTime.now; + return snapshot(settings, DateTime.formatIso(now), { + installed: settings.enabled, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking Kiro CLI availability..." + : "Kiro is disabled in T3 Code settings.", + }); +}); + +const runKiroCli = Effect.fn("KiroProvider.runKiroCli")(function* ( + settings: KiroSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) { + const command = settings.binaryPath || "kiro-cli"; + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); +}); + +const KiroWhoami = Schema.fromJsonString( + Schema.Struct({ + accountType: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + }), +); +const decodeKiroWhoami = Schema.decodeUnknownOption(KiroWhoami); + +/** + * Reads `kiro-cli whoami --format json`: `{"account":null}` with exit 1 means + * signed out. Only the first line is decoded, since an Identity Center login + * follows it with a plain-text `Profile:` block. + */ +export function kiroAuthFromWhoami( + output: { readonly code: number; readonly stdout: string } | undefined, + environment: NodeJS.ProcessEnv, +): ServerProviderAuth { + if (output === undefined) return { status: "unknown" }; + if (output.code !== 0) { + return output.stdout.includes('"account":null') + ? { status: "unauthenticated" } + : { status: "unknown" }; + } + const firstLine = output.stdout.trimStart().split("\n", 1)[0] ?? ""; + const account = Option.getOrUndefined(decodeKiroWhoami(firstLine.trim())); + if (account?.accountType === "ApiKey" || environment[KIRO_API_KEY_ENV]?.trim()) { + return { status: "authenticated", type: "api_key", label: "Kiro API key" }; + } + return { + status: "authenticated", + label: "Kiro account", + ...(account?.email ? { email: account.email } : {}), + }; +} + +const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; +const CLAUDE_4_6_EFFORT_LEVELS = ["low", "medium", "high", "max"]; +const GPT_5_6_EFFORT_LEVELS = ["none", ...CLAUDE_EFFORT_LEVELS]; +const KIMI_K3_EFFORT_LEVELS = ["low", "high", "max"]; + +/** + * Reasoning levels per Kiro model id and its default level, copied from what + * Kiro 2.32.0 advertises, since `--list-models` names none. Unlisted models take + * no effort; the adapter sends only a level the live session offers. + */ +const KIRO_MODEL_EFFORT: Readonly< + Record; readonly defaultLevel: string }> +> = { + "claude-fable-5.1": { levels: CLAUDE_EFFORT_LEVELS, defaultLevel: "high" }, + "claude-opus-5.5": { levels: CLAUDE_EFFORT_LEVELS, defaultLevel: "medium" }, + "claude-opus-5": { levels: CLAUDE_EFFORT_LEVELS, defaultLevel: "high" }, + "claude-opus-4.8": { levels: CLAUDE_EFFORT_LEVELS, defaultLevel: "high" }, + "claude-opus-4.7": { levels: CLAUDE_EFFORT_LEVELS, defaultLevel: "xhigh" }, + "claude-opus-4.6": { levels: CLAUDE_4_6_EFFORT_LEVELS, defaultLevel: "high" }, + "claude-sonnet-5.5": { levels: CLAUDE_EFFORT_LEVELS, defaultLevel: "high" }, + "claude-sonnet-5": { levels: CLAUDE_EFFORT_LEVELS, defaultLevel: "high" }, + "claude-sonnet-4.6": { levels: CLAUDE_4_6_EFFORT_LEVELS, defaultLevel: "high" }, + "gpt-5.6-sol": { levels: GPT_5_6_EFFORT_LEVELS, defaultLevel: "high" }, + "gpt-5.6-terra": { levels: GPT_5_6_EFFORT_LEVELS, defaultLevel: "high" }, + "gpt-5.6-luna": { levels: GPT_5_6_EFFORT_LEVELS, defaultLevel: "high" }, + "kimi-k3": { levels: KIMI_K3_EFFORT_LEVELS, defaultLevel: "max" }, +}; +const KIRO_EFFORT_LABELS: Readonly> = { + none: "None", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +function kiroModelCapabilities(modelId: string): ModelCapabilities { + const effort = KIRO_MODEL_EFFORT[modelId]; + if (effort === undefined) return EMPTY_CAPABILITIES; + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options: effort.levels.map((level) => ({ + value: level, + label: KIRO_EFFORT_LABELS[level] ?? level, + isDefault: level === effort.defaultLevel, + })), + }), + ], + }); +} + +const KiroModelList = Schema.fromJsonString( + Schema.Struct({ + models: Schema.Array( + Schema.Struct({ + model_id: Schema.String, + model_name: Schema.optional(Schema.String), + }), + ), + default_model: Schema.optional(Schema.String), + }), +); +const decodeKiroModelList = Schema.decodeUnknownOption(KiroModelList); + +/** + * Reads `kiro-cli chat --list-models --format json`, the account's models. The + * default entry keeps its name but takes the "default" slug, with Kiro's id as its + * alias. Sessions advertise models only after they start; anything unparsable + * keeps the built-in list. + */ +function kiroModelsFromList( + output: { readonly code: number; readonly stdout: string } | undefined, +): ReadonlyArray | undefined { + if (output === undefined || output.code !== 0) return undefined; + const list = Option.getOrUndefined(decodeKiroModelList(output.stdout.trim())); + if (list === undefined || list.models.length === 0) return undefined; + const defaultId = list.default_model ?? KIRO_FALLBACK_DEFAULT_MODEL; + const listedDefault = list.models.find((model) => model.model_id === defaultId); + const defaultModel: ServerProviderModel = { + slug: KIRO_DEFAULT_MODEL_SLUG, + // Named after the model it runs, so a named default isn't hidden behind "Kiro default". + name: listedDefault ? (listedDefault.model_name ?? listedDefault.model_id) : "Kiro default", + aliases: [defaultId], + isCustom: false, + isDefault: true, + // A new session on Kiro's default gets no model write, so Kiro never + // advertises its effort option and a level could not be applied. + capabilities: EMPTY_CAPABILITIES, + }; + const models = list.models.map((model) => + model === listedDefault + ? defaultModel + : { + slug: model.model_id, + name: model.model_name ?? model.model_id, + isCustom: false, + capabilities: kiroModelCapabilities(model.model_id), + }, + ); + // New threads select "default", so it is listed even when Kiro's list leaves its default out. + return models.includes(defaultModel) ? models : [defaultModel, ...models]; +} + +/** + * Kiro's own id for "Kiro default": the alias the model list gave it, else + * `auto`, Kiro's documented default. + */ +export function kiroDefaultModelId(models: ReadonlyArray): string { + return ( + models.find((model) => model.slug === KIRO_DEFAULT_MODEL_SLUG)?.aliases?.[0] ?? + KIRO_FALLBACK_DEFAULT_MODEL + ); +} + +const KiroConfigTemplate = Schema.Struct({ + configOptions: Schema.Array( + Schema.Struct({ + id: Schema.String, + type: Schema.String, + currentValue: Schema.optional(Schema.Unknown), + options: Schema.optional( + Schema.Array( + Schema.Struct({ + value: Schema.String, + name: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), +}); +const decodeKiroConfigTemplate = Schema.decodeUnknownOption(KiroConfigTemplate); + +/** + * The thread's agent picker, from Kiro's `_kiro/config/template` result, without + * opening a session. The template reads no workspace, so a project's + * `.kiro/agents` are not offered. + */ +export function kiroAgentDescriptor(template: unknown): ProviderOptionDescriptor | undefined { + const decoded = Option.getOrUndefined(decodeKiroConfigTemplate(template)); + const mode = decoded?.configOptions.find( + (option) => option.id === KIRO_AGENT_CONFIG_ID && option.type === "select", + ); + const defaultAgent = typeof mode?.currentValue === "string" ? mode.currentValue : undefined; + const choices = (mode?.options ?? []).flatMap((choice) => { + const id = choice.value.trim(); + const label = choice.name.trim() || id; + const description = choice.description?.trim(); + return id.length === 0 + ? [] + : [ + { + id, + label, + ...(description ? { description } : {}), + ...(id === defaultAgent ? { isDefault: true } : {}), + }, + ]; + }); + // One agent is nothing to choose between. + if (choices.length < 2) return undefined; + return { + id: KIRO_AGENT_CONFIG_ID, + label: "Agent", + type: "select", + options: choices, + ...(defaultAgent !== undefined && choices.some((choice) => choice.isDefault) + ? { currentValue: defaultAgent } + : {}), + }; +} + +/** + * Asks Kiro for its agents with `initialize` and `_kiro/config/template` and + * nothing more: no `session/new`, so no MCP server starts and no session is + * stored. The process runs in the server's working directory. + */ +const probeKiroAgents = Effect.fn("KiroProvider.probeKiroAgents")(function* ( + settings: KiroSettings, + environment: NodeJS.ProcessEnv, +) { + const runtime = yield* makeKiroAcpRuntime({ + settings, + environment, + cwd: yield* HostProcess.WorkingDirectory, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + yield* runtime.initialize(); + return kiroAgentDescriptor(yield* runtime.request("_kiro/config/template", {})); +}, Effect.scoped); + +export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(function* ( + settings: KiroSettings, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!settings.enabled) { + return snapshot(settings, checkedAt, { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kiro is disabled in T3 Code settings.", + }); + } + + const versionResult = yield* runKiroCli(settings, ["--version"], environment).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult) || Option.isNone(versionResult.success)) { + const missing = Result.isFailure(versionResult) && isCommandMissingCause(versionResult.failure); + return snapshot(settings, checkedAt, { + installed: !missing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: missing + ? "Kiro CLI (`kiro-cli`) is not installed or not on PATH." + : "Failed to run `kiro-cli --version`.", + }); + } + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return snapshot(settings, checkedAt, { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Kiro CLI is installed but failed to run.", + }); + } + + const whoamiResult = yield* runKiroCli( + settings, + ["whoami", "--format", "json"], + environment, + ).pipe(Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.result); + const auth = kiroAuthFromWhoami( + Result.isSuccess(whoamiResult) ? Option.getOrUndefined(whoamiResult.success) : undefined, + environment, + ); + if (auth.status === "unauthenticated") { + return snapshot(settings, checkedAt, { + installed: true, + version, + status: "error", + auth, + message: "Kiro CLI is installed but not signed in. Run `kiro-cli login`.", + }); + } + // Signed out, `--list-models` starts a browser login instead, and the agent + // probe fails to start, so both run only once `whoami` has confirmed the + // account. Without an agent list the composer shows no agent picker. + const [listed, agents] = + auth.status === "authenticated" + ? yield* Effect.all( + [ + runKiroCli(settings, ["chat", "--list-models", "--format", "json"], environment).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.map((output) => kiroModelsFromList(Option.getOrUndefined(output))), + Effect.orElseSucceed(() => undefined), + ), + probeKiroAgents(settings, environment).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ), + ], + { concurrency: "unbounded" }, + ) + : [undefined, undefined]; + return snapshot( + settings, + checkedAt, + { + installed: true, + version, + status: auth.status === "authenticated" ? "ready" : "warning", + auth, + ...(auth.status === "authenticated" + ? {} + : { message: "Could not confirm the Kiro sign-in. Run `kiro-cli whoami` to check." }), + }, + listed, + agents, + ); +}); diff --git a/packages/provider-kiro/src/settings.ts b/packages/provider-kiro/src/settings.ts new file mode 100644 index 0000000000000..4ae61a3910907 --- /dev/null +++ b/packages/provider-kiro/src/settings.ts @@ -0,0 +1,32 @@ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const KiroSettings = makeProviderSettingsSchema( + { + // Off by default like Grok: users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("kiro-cli").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Kiro CLI binary.", + providerSettingsForm: { placeholder: "kiro-cli", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type KiroSettings = typeof KiroSettings.Type; diff --git a/packages/provider-kiro/src/testing.ts b/packages/provider-kiro/src/testing.ts new file mode 100644 index 0000000000000..3f18a713ad8c3 --- /dev/null +++ b/packages/provider-kiro/src/testing.ts @@ -0,0 +1,12 @@ +export { KIRO_PROVIDER, makeKiroAdapterV2, type KiroAdapterV2Options } from "./server/adapter.ts"; +export { + kiroApprovalOptions, + kiroAutopilotValue, + kiroPermissionDisposition, + makeKiroAcpRuntime, +} from "./server/acpSupport.ts"; +export { + checkKiroProviderStatus, + kiroAgentDescriptor, + kiroDefaultModelId, +} from "./server/status.ts"; diff --git a/packages/provider-kiro/tsconfig.json b/packages/provider-kiro/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/provider-kiro/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index fe0826e7a0026..2281365ea23e8 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -3,9 +3,15 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; import type { OpenCodeSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; @@ -59,7 +65,7 @@ const layer = Layer.mergeAll( ProviderEventLoggers.NoOpProviderEventLoggers, ), Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const create = (config: Partial, http: HttpClient.HttpClient) => OpenCodeDriver.create({ @@ -458,7 +464,7 @@ const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) => ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")), ), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( @@ -540,7 +546,7 @@ const layerUpdate = Layer.mergeAll( ProviderEventLoggers.NoOpProviderEventLoggers, ), Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( @@ -574,4 +580,39 @@ it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { assert.isNull(v2.update); }).pipe(Effect.scoped), ); + + it.effect( + "opens sessions through OpenCode 2 after the binary is upgraded in place from 1.x", + () => + Effect.gen(function* () { + versionOutput.current = "1.18.18\n"; + const instance = yield* create({}, noHttp); + // Let the startup status check claim the refresh permit, then wait behind it so it + // cannot probe after the upgrade below. + yield* Effect.yieldNow; + yield* instance.snapshot.refresh; + serverStarts.length = 0; + const openSession = instance.orchestrationAdapter + .openSession({ + threadId: ThreadId.make("thread-upgraded"), + providerSessionId: ProviderSessionId.make("session-upgraded"), + modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }) + .pipe(Effect.scoped, Effect.flip); + + yield* openSession; + assert.deepStrictEqual(serverStarts, ["connect"], "1.x opens through the 1.x adapter"); + + versionOutput.current = "opencode v2.2.4\n"; + serverStarts.length = 0; + yield* openSession; + assert.notInclude( + serverStarts, + "connect", + "after upgrading to 2.x, the next session must not open through the 1.x adapter", + ); + assert.include(serverStarts, "start", "the OpenCode 2 adapter starts its server"); + }).pipe(Effect.scoped), + ); }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 7a7f634857c86..cf9b97bcaa3a0 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -1,6 +1,7 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -172,7 +173,8 @@ export type OpenCodeDriverEnv = | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -206,7 +208,7 @@ export const OpenCodeDriver: ProviderDriver< const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const httpClient = yield* HttpClient.HttpClient; const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const host = yield* ProviderHost.ProviderHost; const processEnv = yield* mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -357,7 +359,7 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), ); const checkProvider = Effect.all( { diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index b96036669b599..802f544ae6f2d 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -1,6 +1,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeSqlite from "node:sqlite"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -73,7 +74,7 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res ); }), ), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); NodeAssert.equal(limits.unavailable, undefined); NodeAssert.equal( @@ -223,7 +224,7 @@ it.effect( consoleStatus === 500 ? undefined : fingerprints.get(requested.at(-1)!), ); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("prefers the selected stored Go key over the auth.json key OpenCode 2 imported", () => @@ -292,7 +293,7 @@ it.effect("prefers the selected stored Go key over the auth.json key OpenCode 2 NodeAssert.equal(limits.unavailable, undefined); NodeAssert.equal(limits.windows.length, 3); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("does not read local credentials for external or disabled OpenCode instances", () => @@ -312,7 +313,7 @@ it.effect("does not read local credentials for external or disabled OpenCode ins HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected usage request")), ), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); NodeAssert.equal(limits.unavailable?.reason, "unsupported"); } @@ -345,7 +346,7 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), ), ), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); NodeAssert.equal(limits.unavailable?.reason, reason); NodeAssert.equal(limits.credentialFingerprint, undefined); @@ -477,7 +478,7 @@ beforeEach(() => { }); const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index e691dc211f921..0f97ea36cfefa 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -1,7 +1,7 @@ import * as NodeSqlite from "node:sqlite"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -164,10 +164,10 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), }, ]; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; // Unkeyed hashes match across environments; random Go keys resist offline guessing. - const credentialFingerprint = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`)) + const credentialFingerprint = yield* digest + .digest(Digest.sha256, new TextEncoder().encode(`opencode-go\0${apiKey}`)) .pipe(Effect.map(Hex.encode), Effect.orDie); return { ...makeUsageLimits({ checkedAt, windows }), diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index cee3372e14699..cf99beeecb256 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -1,7 +1,9 @@ import { assert, it } from "@effect/vitest"; +import { Session } from "@opencode/client/effect"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { describe } from "vite-plus/test"; @@ -45,3 +47,89 @@ describe("OpenCode2Client authorization", () => { }), ); }); + +const previous = { + id: "msg_repro088", + type: "synthetic", + text: "Fixture message 88", + time: { created: 1088 }, +}; + +const compaction = (fields: Record) => ({ + type: "compaction", + id: "msg_repro089", + time: { created: 1089 }, + reason: "manual", + summary: "Fixture compaction", + ...fields, +}); + +const listHistory = (messages: ReadonlyArray) => + Effect.gen(function* () { + const transport = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + assert.include(request.url, "/api/session/ses_repro/message"); + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ data: messages, cursor: {} }), { + headers: { "content-type": "application/json" }, + }), + ); + }), + ), + ); + const opencode = yield* OpenCode2Client.OpenCode2Client.pipe( + Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(transport))), + ); + const { client } = yield* opencode.connect({ + baseUrl: "http://127.0.0.1:4096", + password: "test-password", + }); + return yield* client.message.list({ sessionID: Session.ID.make("ses_repro"), order: "asc" }); + }); + +describe("OpenCode2Client message history", () => { + it.effect.each([ + { status: "running", shape: "native keepID", fields: { metadata: { keepID: previous.id } } }, + { status: "completed", shape: "native keepID", fields: { metadata: { keepID: previous.id } } }, + { status: "running", shape: "native without metadata", fields: {} }, + { status: "completed", shape: "native without metadata", fields: {} }, + { + status: "completed", + shape: "legacy metadata", + fields: { metadata: { recent: previous.id } }, + }, + { status: "running", shape: "top-level", fields: { recent: previous.id } }, + ] as const)("decodes a $status compaction in the $shape format", ({ status, fields }) => + Effect.gen(function* () { + const history = yield* listHistory([previous, compaction({ status, ...fields })]); + + assert.deepStrictEqual( + history.data.map((message) => message.id), + [previous.id, "msg_repro089"], + ); + assert.deepInclude(history.data[1], { + type: "compaction", + status, + summary: "Fixture compaction", + recent: + "metadata" in fields && "recent" in fields.metadata ? previous.id : (fields.recent ?? ""), + }); + }), + ); + + it.effect.each([ + { field: "recent", fields: { status: "completed", recent: 42 } }, + { field: "reason", fields: { status: "completed", reason: "scheduled", recent: previous.id } }, + ] as const)("keeps a compaction with an invalid $field an error", ({ field, fields }) => + Effect.gen(function* () { + const error = yield* listHistory([compaction(fields)]).pipe(Effect.flip); + + assert.strictEqual(error._tag, "ClientError"); + assert.isTrue(Schema.isSchemaError(error.cause)); + assert.include(String(error.cause), field); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts index 79f6d5f917366..dc7de72ae13f9 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -142,6 +142,62 @@ const basicAuthorization = (password: string | Redacted.Redacted) => { return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`; }; +const MESSAGE_LIST_URL = /\/api\/session\/[^/?#]+\/message(?:[?#]|$)/; + +/** Reads only the page's shape; each message stays opaque for the real schema to judge. */ +const MessagePage = Schema.fromJsonString( + Schema.StructWithRest(Schema.Struct({ data: Schema.Array(Schema.Unknown) }), [ + Schema.Record(Schema.String, Schema.Unknown), + ]), +); +const decodePage = Schema.decodeUnknownOption(MessagePage); +const encodePage = Schema.encodeSync(MessagePage); + +const CompactionWithRecent = Schema.Struct({ + type: Schema.Literal("compaction"), + status: Schema.Literals(["running", "completed"]), + metadata: Schema.optionalKey(Schema.Unknown), +}); +const isCompactionWithRecent = Schema.is(CompactionWithRecent); +const isLegacyMetadata = Schema.is(Schema.Struct({ recent: Schema.String })); + +const fillCompactionRecent = (message: unknown) => + isCompactionWithRecent(message) && !("recent" in message) + ? { ...message, recent: isLegacyMetadata(message.metadata) ? message.metadata.recent : "" } + : message; + +/** + * OpenCode 2.1 dropped a compaction's `recent`, which the bundled 2.0.28 schema requires. + * Migrated pre-2.1 compactions keep it under `metadata.recent`; native 2.1 ones have none. + * T3 never reads it, so fill the gap; every other field still validates downstream. + */ +const fillCompactionRecents = HttpClient.transformResponse( + Effect.fnUntraced(function* ( + pending: Effect.Effect, + ) { + const response = yield* pending; + const { request } = response; + if ( + request.method !== "GET" || + response.status !== 200 || + !MESSAGE_LIST_URL.test(request.url) + ) { + return response; + } + const text = yield* response.text; + const page = decodePage(text); + // The response caches its body, so the untouched one still decodes downstream. + if (Option.isNone(page)) return response; + const filled = encodePage({ ...page.value, data: page.value.data.map(fillCompactionRecent) }); + // The filled body is longer than the one the server measured. + const { "content-length": _, ...headers } = response.headers; + return HttpClientResponse.fromWeb( + request, + new Response(filled, { status: response.status, headers }), + ); + }), +); + /** @public */ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; @@ -152,7 +208,7 @@ export const make = Effect.gen(function* () { HttpClientRequest.setHeader("Authorization", basicAuthorization(password)), ); return OpenCode.make({ baseUrl }).pipe( - Effect.provideService(HttpClient.HttpClient, authenticated), + Effect.provideService(HttpClient.HttpClient, fillCompactionRecents(authenticated)), Effect.map((client) => ({ client, events: readEvents.pipe( diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts index d1e9693fb67b7..dec32dc90d006 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -111,7 +112,7 @@ describe("OpenCode2Server error details", () => { Layer.mergeAll( OpenCode2Client.layer.pipe(Layer.provide(httpClient)), OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ).pipe(Layer.provideMerge(NodeServices.layer)), + ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ); @@ -213,7 +214,11 @@ describe("OpenCode2Server spawned server", () => { Layer.mergeAll( OpenCode2Client.layer, OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), + ).pipe( + Layer.provideMerge( + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, FetchHttpClient.layer), + ), + ), ), ), ); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts index c8c5dd7b70b64..57ba94d3231aa 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts @@ -1,7 +1,7 @@ // One OpenCode 2 process serves all directories; share it across instance threads. import type { OpenCodeClient } from "@opencode/client/effect"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as P from "effect/Predicate"; @@ -34,8 +34,8 @@ export class OpenCode2Server extends Context.Service< * otherwise, so T3 supplies its own and keeps it in memory. */ const generatePassword = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie); + const random = yield* SecureRandom.SecureRandom; + const bytes = yield* random.randomBytes(32); return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" }); }); diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 364982bf4d7f9..1217963d2634d 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -40,7 +40,7 @@ import { import type * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Exit from "effect/Exit"; @@ -213,7 +213,12 @@ interface ActiveTurn { output: number; reasoning: number; }; + // Steps OpenCode started; a step that ends without tokens leaves the total partial. + startedSteps: number; + // A reconnect dropped events, so steps may have ended unseen. + missedEvents: boolean; steps: number; + readonly models: Set; lastStep: Tokens | undefined; before: string | null | undefined; compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; @@ -277,6 +282,7 @@ interface OpenBlock { interface ThreadState { readonly sessionId: string; providerThread: OrchestrationV2ProviderThread; + publishedBackgroundWork?: boolean; readonly providerTurns: Map; active: ActiveTurn | undefined; model: ModelRef | undefined; @@ -354,9 +360,9 @@ const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resour export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) { const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; if (name.length <= 64) return name; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(threadId)) + .digest(Digest.sha256, new TextEncoder().encode(threadId)) .pipe(Effect.orDie); return `t3-code-${Hex.encode(digest).slice(0, 16)}`; }); @@ -523,6 +529,12 @@ const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake: const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; +// OpenCode's claude-agent reports a spent Claude plan as provider.unknown with Claude's own text. +const claudeUsageLimit = (message: string | undefined) => + message !== undefined && /^You've hit your [^\n·]*\blimit\b/.test(message) + ? { resetAt: /\bResets at (\d{4}-\d\d-\d\dT[\d:.]+Z)\.?\s*$/.exec(message)?.[1] ?? null } + : undefined; + const INTERRUPT_TIMEOUT = "10 seconds"; const INSTRUCTIONS_KEY = "t3-code"; const RECONNECT_ATTEMPTS = 5; @@ -623,26 +635,30 @@ const commandOf = (text: string) => { return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" }; }; -const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u"); - -const skillsNamed = (text: string, known: ReadonlySet) => [ - ...new Set( - [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => - known.has(match[2] ?? "") ? [match[2]!] : [], - ), - ), +const skillMentions = (text: string) => [ + ...new Set([...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => match[2] ?? [])), ]; -const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => - turn.steps === 0 +const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => { + // A step whose start was missed, in a reconnect gap or as an unreadable event, ran on an unreported model. + const models = + turn.models.size === 0 || turn.missedEvents || turn.steps > turn.startedSteps + ? {} + : { models: [...turn.models] }; + return turn.steps === 0 ? { usageScope: "main_agent" as const, usageStatus: "unavailable" as const, hasSubagents: turn.usedSubagents, + ...models, } : { + ...models, usageScope: "main_agent" as const, - usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const), + usageStatus: + status === "completed" && !turn.missedEvents && turn.steps >= turn.startedSteps + ? ("complete" as const) + : ("partial" as const), inputTokens: turn.usage.input, cachedInputTokens: turn.usage.cached, cacheCreationTokens: turn.usage.cacheWrite, @@ -650,6 +666,7 @@ const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["s reasoningTokens: turn.usage.reasoning, hasSubagents: turn.usedSubagents, }; +}; export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) { const server = yield* OpenCode2Server.OpenCode2Server; @@ -658,10 +675,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const fileSystem = yield* FileSystem.FileSystem; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const driver = OPENCODE_PROVIDER; const mcpServerNameFor = (threadId: string) => - t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto)); + t3McpServerName(threadId).pipe(Effect.provideService(Digest.Digest, crypto)); const borrow = Effect.gen(function* () { const lent = yield* Deferred.make< @@ -821,6 +838,17 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork); + const emitProviderThread = Effect.fnUntraced(function* (state: ThreadState) { + const pendingBackgroundWork = hasBackground(state); + state.publishedBackgroundWork = pendingBackgroundWork; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + pendingBackgroundWork, + }); + }); + const setSessionStatus = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced(function* ( @@ -1115,7 +1143,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ordinals: new Map(), nextOrdinal: providerTurn.ordinal * 100 + 1, usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 }, + startedSteps: 0, + missedEvents: false, steps: 0, + models: new Set(), lastStep: undefined, before: undefined, compaction: undefined, @@ -1217,7 +1248,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr result: call.result, }, }); - yield* syncRoster(rootOf(call.state)); + yield* syncRosterAncestors(call.state); }); const syncRoster = Effect.fnUntraced(function* (state: ThreadState) { @@ -1233,6 +1264,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr })); const current = state.providerThread.pendingBackgroundTasks ?? []; if ( + state.publishedBackgroundWork === hasBackground(state) && current.length === roster.length && current.every( (task, index) => @@ -1249,11 +1281,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr pendingBackgroundTasks: roster, updatedAt: yield* DateTime.now, }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: state.providerThread, - }); + yield* emitProviderThread(state); + }); + + const syncRosterAncestors = Effect.fnUntraced(function* (state: ThreadState) { + let current: ThreadState | undefined = state; + while (current !== undefined) { + yield* syncRoster(current); + current = current.subagent?.call.state; + } }); const withReportedModel = ( @@ -1363,11 +1399,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr childOwners.set(childId, rootOf(call.state)); call.child = child; yield* emit({ type: "app_thread.created", driver, appThread }); - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: child.providerThread, - }); + yield* emitProviderThread(child); yield* emitSubagent(call); // Reused child sessions may retain old mode rules; refresh policy as soon as the call identifies them. if (info === undefined) { @@ -1402,11 +1434,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr status: "idle", updatedAt: call.completedAt, }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: child.providerThread, - }); + yield* emitProviderThread(child); } yield* emitSubagent(call); }); @@ -1620,11 +1648,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr status: threadDisposition === "broken" ? "error" : "idle", updatedAt: completedAt, }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: state.providerThread, - }); + yield* emitProviderThread(state); const anyActive = [...threads.values()].some( (candidate) => candidate.subagent === undefined && candidate.active !== undefined, ); @@ -2105,6 +2129,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* emitCompaction(state, turn, "completed", event.data.text); case "session.compaction.failed": return yield* emitCompaction(state, turn, "failed"); + case "session.step.started": + turn.startedSteps += 1; + turn.models.add(`${event.data.model.providerID}/${event.data.model.id}`); + return; case "session.step.ended": case "session.step.failed": { const tokens = event.data.tokens; @@ -2133,14 +2161,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const { type, message, status, response } = event.data.error; // OpenCode types an empty balance as provider.quota too, but it answers // that with HTTP 402 and it never resets. Usage windows and budgets are 429. + const claudeLimit = claudeUsageLimit(message); const usageLimit = - (type === "provider.quota" && status !== 402) || type === "provider.rate-limit"; + (type === "provider.quota" && status !== 402) || + type === "provider.rate-limit" || + claudeLimit !== undefined; return yield* endExecution(state, turn, { status: "failed", failure: makeProviderFailure({ message: readableProviderErrorMessage(message, status, response?.body), code: type, class: usageLimit ? "usage_limit" : "provider_error", + resetAt: claudeLimit?.resetAt ?? null, }), }); } @@ -2383,11 +2415,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const providerThread = withReportedModel(state.providerThread, event.data.model); if (providerThread !== state.providerThread) { state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: state.providerThread, - }); + yield* emitProviderThread(state); } } if ( @@ -2572,6 +2600,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr for (const [sessionId, state] of running) { const turn = state.active; if (turn === undefined) continue; + turn.missedEvents = true; const ended = yield* backfill(sessionId, state); if (sessionId in active || state.active !== turn) continue; yield* finishTurn( @@ -2597,10 +2626,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* settleCall(call, "interrupted", LOST_BACKGROUND); } } - for (const session of sessionsOf(state)) { + const sessions = sessionsOf(state); + for (const session of sessions) { discardWakes(session, session.wakes.splice(0)); session.reports.clear(); } + for (const session of sessions.toReversed()) yield* syncRoster(session); } // Stopped off the lock this reconcile holds; one still running waits for a Stop. for (const thread of new Set([...strays.values()].map(rootOf))) { @@ -2656,7 +2687,26 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr stream: Stream.Stream, ): Effect.Effect => stream.pipe( - Stream.runForEach((event) => lock.withPermit(handleEvent(event))), + Stream.runForEach((event) => + lock.withPermit( + Effect.gen(function* () { + yield* handleEvent(event); + if ( + event.type === "session.execution.started" || + event.type === "unreadable.execution.started" || + executionEnd(event.type) || + event.type === "unreadable.execution.ended" || + event.type === "session.inbox.enqueued" || + event.type === "session.inbox.delivered" || + event.type === "session.inbox.cancelled" + ) { + const sessionId = sessionOfEvent(event); + const state = sessionId === undefined ? undefined : threads.get(sessionId); + if (state !== undefined) yield* syncRosterAncestors(state); + } + }), + ), + ), Effect.exit, Effect.flatMap(() => Effect.gen(function* () { @@ -2905,12 +2955,19 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); }); - // The API accepts one message type; boundaries require both user and synthetic messages. - const userMessages = (sessionId: string) => - OpenCode2Client.paginate( - { sessionID: Session.ID.make(sessionId), limit: 100 }, - client.message.list, - ).pipe( + // Rollback must validate full history before native mutation; forks only need prompts. + const userMessages = (sessionId: string, types?: ReadonlyArray<"user" | "synthetic">) => + Stream.fromIterable(types ?? [undefined]).pipe( + Stream.flatMap((type) => + OpenCode2Client.paginate( + { + sessionID: Session.ID.make(sessionId), + limit: 100, + ...(type === undefined ? {} : { type }), + }, + client.message.list, + ), + ), Stream.runCollect, Effect.map( (messages) => @@ -3149,11 +3206,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr lastRunOrdinal: turnInput.runOrdinal, updatedAt: startedAt, }; - yield* emit({ - type: "provider_thread.updated", - driver, - providerThread: state.providerThread, - }); + yield* emitProviderThread(state); yield* setSessionStatus("running", null); return turn; }); @@ -3209,6 +3262,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (wake?.running !== true) return; state.wakes.pop(); yield* replay(wake); + for (const session of sessionsOf(state).toReversed()) yield* syncRoster(session); + yield* syncRosterAncestors(state); }); const runWake = Effect.fnUntraced(function* ( @@ -3234,6 +3289,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } if (wake.running) state.unsettled = false; yield* replay(wake); + for (const session of sessionsOf(state).toReversed()) yield* syncRoster(session); + yield* syncRosterAncestors(state); }), ); }); @@ -3296,6 +3353,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const running = state.wakes.some((wake) => wake.running); discardWakes(state, state.wakes.splice(0)); + yield* syncRosterAncestors(state); if (running) { markUnsettled(state); yield* client.session @@ -3307,6 +3365,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* stopStrays(state); }); + const listSkills = (location: { readonly directory: string }) => + client.skill.list({ location }).pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.map((list) => new Set(list.data.map((skill) => skill.id))), + Effect.orElseSucceed(() => new Set()), + ); + + // A directory OpenCode is still scanning lists none or some of its skills. + // OpenCode 2.2.5 answers its model list only once the scan has ended (observed, not documented). + const skillsMentioned = Effect.fnUntraced(function* ( + location: { readonly directory: string }, + mentions: ReadonlyArray, + ) { + const known = yield* listSkills(location); + if (mentions.some((name) => !known.has(name))) { + yield* readModels(location.directory); + for (const name of yield* listSkills(location)) known.add(name); + } + return mentions.filter((name) => known.has(name)); + }); + const submit = Effect.fnUntraced(function* ( sessionId: string, turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, @@ -3342,16 +3421,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* client.session.command({ sessionID, ...command }); } } - const skills = SKILL_MENTION.test(text) - ? skillsNamed( - text, - yield* client.skill.list({ location }).pipe( - Effect.timeout(INVENTORY_TIMEOUT), - Effect.map((list) => new Set(list.data.map((skill) => skill.id))), - Effect.orElseSucceed(() => new Set()), - ), - ) - : []; + const mentions = skillMentions(text); + const skills = mentions.length === 0 ? [] : yield* skillsMentioned(location, mentions); const files = yield* promptFiles(turnInput.message.attachments); const messageText = yield* promptText(turnInput); if (!sending()) return; @@ -4011,7 +4082,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr forkInput.sourceProviderTurns ?? [], source.id, selected, - yield* userMessages(sourceId), + yield* userMessages(sourceId, ["user", "synthetic"]), ); const forked = yield* client.session.fork({ sessionID: Session.ID.make(sourceId), diff --git a/packages/provider-opencode/src/server/versionProbe.test.ts b/packages/provider-opencode/src/server/versionProbe.test.ts index d698bbcb8785b..c6a9536f774f3 100644 --- a/packages/provider-opencode/src/server/versionProbe.test.ts +++ b/packages/provider-opencode/src/server/versionProbe.test.ts @@ -160,9 +160,9 @@ describe("OpenCode version probe", () => { }), ); - it.effect("remembers a probed runtime but never a failure", () => + it.effect("remembers a probed 2.x runtime but re-probes 1.x and failures", () => Effect.gen(function* () { - const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"]; + const outputs = ["", "opencode v2.0.18\n", "1.18.32\n", "1.18.32\n"]; let calls = 0; const runtime = { runOpenCodeCommand: () => { @@ -190,8 +190,9 @@ describe("OpenCode version probe", () => { assert.strictEqual((yield* probe.get).generation, "v2"); assert.strictEqual(calls, 2); assert.strictEqual((yield* probe.refresh).generation, "v1"); - assert.strictEqual((yield* probe.get).generation, "v1"); assert.strictEqual(calls, 3); + assert.strictEqual((yield* probe.get).generation, "v1"); + assert.strictEqual(calls, 4); }), ); }); diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts index 5f0f871d5213f..ab40142de845a 100644 --- a/packages/provider-opencode/src/server/versionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -172,11 +172,14 @@ export const probeOpenCodeRuntime = ( ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) : probeOpenCodeBinary(settings.binaryPath, environment); +// Only 2.x is kept: 1.x can be upgraded in place while T3 runs, and 2.x converts the shared +// database, so going back is unsupported. export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect) => Effect.map( Cache.makeWith(() => probe, { capacity: 1, - timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.generation === "v2" ? Duration.infinity : Duration.zero, }), (cache) => ({ get: Cache.get(cache, undefined), diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 5c94de3040209..0afd6960485de 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -161,10 +161,11 @@ interface FakePi { readonly vetoNextSwitch: () => void; readonly queueState: (data: Record) => void; readonly setStreaming: (streaming: boolean) => void; + readonly setCompacting: (compacting: boolean) => void; readonly deferNextState: () => void; readonly resolveDeferredState: (data: unknown) => Effect.Effect; readonly failNextState: () => void; - readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; + readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork" | "abort") => void; readonly deferNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; @@ -179,7 +180,7 @@ interface FakePi { readonly closeStdout: Effect.Effect; readonly lastSpawn: () => { readonly args: ReadonlyArray; - readonly env: NodeJS.ProcessEnv; + readonly env: NonNullable; }; } @@ -197,6 +198,10 @@ const recordedIdleState = (sessionFile: string) => ({ pendingMessageCount: 0, }); +/** Pi 1.4.0 rejects every prompt while compaction runs, including steers. */ +const PI_COMPACTING_PROMPT_ERROR = + "Cannot submit a prompt while compaction is in progress. Wait for compaction to finish and retry."; + const makeFakePi: Effect.Effect = Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const requests = yield* Queue.unbounded(); @@ -215,6 +220,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let sessionGeneration = 0; let models: ReadonlyArray = []; let isStreaming: boolean | null = null; + let isCompacting = false; let clearQueue: Parameters[0] = { steering: [], followUp: [] }; let stdinBuffer = ""; @@ -228,7 +234,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }); const respondTo = (record: PiRpcRecord): PiRpcRecord | null => { - if (typeof record["id"] !== "string" && !(record["type"] === "prompt" && isStreaming !== null)) + if ( + typeof record["id"] !== "string" && + !(record["type"] === "prompt" && (isStreaming !== null || isCompacting)) + ) return null; const base = { type: "response", @@ -251,6 +260,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }, }; case "prompt": + if (isCompacting) { + return { ...base, success: false, error: PI_COMPACTING_PROMPT_ERROR }; + } if (isStreaming && record["streamingBehavior"] === undefined) { return { ...base, @@ -316,7 +328,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } }); - let lastSpawn: { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv } = { + let lastSpawn: ReturnType = { args: [], env: {}, }; @@ -324,7 +336,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { Effect.sync(() => { if (ChildProcess.isStandardCommand(command)) { lastSpawn = { - args: command.args, + args: command.args.map(String), env: command.options.env ?? {}, }; } @@ -397,6 +409,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { setStreaming: (streaming) => { isStreaming = streaming; }, + setCompacting: (compacting) => { + isCompacting = compacting; + }, queueStats: (data) => statsQueue.push(data), setClearQueue: (result) => { clearQueue = result; @@ -791,7 +806,7 @@ describe("PiAdapterV2", () => { ); it.effect.each([false, true])( - "retains native wake ownership after a rejected joining prompt, compaction aborted=%s", + "retains native wake ownership and delivers a joining prompt held through compaction, aborted=%s", (aborted) => Effect.gen(function* () { const fake = yield* makeFakePi; @@ -818,9 +833,6 @@ describe("PiAdapterV2", () => { yield* fake.emit({ type: "agent_settled" }); yield* fake.emit({ type: "compaction_start", reason: "manual" }); yield* startTurn(runtime, providerThread); - yield* fake.takeRequest("prompt"); - const rejection = "Cannot submit a prompt while compaction is in progress."; - yield* fake.emit({ type: "response", command: "prompt", success: false, error: rejection }); yield* fake.emit({ type: "extension_ui_request", id: "native-compaction-dialog", @@ -836,22 +848,21 @@ describe("PiAdapterV2", () => { decision: "accept", }); yield* fake.takeRequest("extension_ui_response"); - if (aborted) { - yield* fake.emit({ type: "compaction_end", result: null, aborted: true }); - const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); - assert.isTrue( - terminal.type === "turn.terminal" && - terminal.status === "failed" && - terminal.failure.message === rejection, - ); - return; - } + assert.isFalse(fake.allRequests().some((request) => request["type"] === "prompt")); + yield* fake.emit( + aborted + ? { type: "compaction_end", result: null, aborted: true } + : { + type: "compaction_end", + result: { summary: "Native compaction finished" }, + willRetry: true, + }, + ); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "Hello pi"); fake.queueState({ isStreaming: true }); - yield* fake.emit({ - type: "compaction_end", - result: { summary: "Native compaction finished" }, - willRetry: true, - }); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.takeRequest("get_state"); yield* fake.emit({ type: "agent_start" }); yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); yield* fake.emit({ @@ -864,13 +875,12 @@ describe("PiAdapterV2", () => { }); const message = yield* takeEvent((event) => event.type === "message.updated"); assert.isTrue(message.type === "message.updated" && message.message.runId !== null); - yield* fake.takeRequest("get_state"); yield* fake.emit({ type: "agent_settled" }); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); - assert.isTrue( - terminal.type === "turn.terminal" && - terminal.status === "failed" && - terminal.failure.message === rejection, + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + assert.equal( + fake.allRequests().filter((request) => request["type"] === "prompt").length, + 1, ); }).pipe(Effect.scoped, Effect.provide(layerTest)), ); @@ -4138,6 +4148,310 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("delivers a turn-start prompt sent during detached compaction once it ends", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + const first = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(first.type === "turn.terminal" && first.status === "completed"); + + fake.setCompacting(true); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + // The dialog proves the pump observed compaction_start before the prompt. + yield* fake.emit({ + type: "extension_ui_request", + id: "detached-compaction-dialog", + method: "confirm", + title: "Continue?", + }); + yield* takeEvent((event) => event.type === "runtime_request.updated"); + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Sent during compaction", + undefined, + 2, + ); + // Writes go out in order, so this round trip flushes any prompt sent before it. + yield* runtime.readThreadSnapshot({ providerThread }); + fake.setCompacting(false); + const compactionEnded = fake.allRequests().length; + assert.isFalse( + fake + .allRequests() + .some( + (request) => + request["type"] === "prompt" && request["message"] === "Sent during compaction", + ), + ); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, + aborted: false, + willRetry: false, + }); + const delivered = yield* fake.takeRequest("prompt"); + assert.equal(delivered["message"], "Sent during compaction"); + yield* fake.emit({ type: "agent_start" }); + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal( + fake + .allRequests() + .slice(compactionEnded) + .filter( + (request) => + request["type"] === "prompt" && request["message"] === "Sent during compaction", + ).length, + 1, + ); + assert.isTrue( + terminal.type === "turn.terminal" && terminal.status === "completed", + `turn ended ${terminal.type === "turn.terminal" ? terminal.status : terminal.type}`, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([ + { error: PI_COMPACTING_PROMPT_ERROR, retried: true }, + { error: "Model unavailable", retried: false }, + ])( + "retries a prompt Pi rejected because compaction started first, retried=$retried", + ({ error, retried }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* takeEvent((event) => event.type === "turn.terminal"); + + yield* startTurn(runtime, providerThread, "default", [], "Raced compaction", undefined, 2); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* fake.emit({ + type: "response", + command: "prompt", + success: false, + error, + }); + if (!retried) { + const failed = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + failed.type === "turn.terminal" && + failed.status === "failed" && + failed.failure.message === error, + ); + return; + } + const compactionEnded = fake.allRequests().length; + yield* fake.emit({ type: "compaction_end", result: null, aborted: true }); + const retriedPrompt = yield* fake.takeRequest("prompt"); + assert.equal(retriedPrompt["message"], "Raced compaction"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + terminal.type === "turn.terminal" && terminal.status === "completed", + `turn ended ${terminal.type === "turn.terminal" ? terminal.status : terminal.type}`, + ); + assert.equal( + fake + .allRequests() + .slice(compactionEnded) + .filter((request) => request["type"] === "prompt").length, + 1, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([{ restart: false }, { restart: true }])( + "restarts Pi when Stop interrupts a prompt held for detached compaction, restart=$restart", + ({ restart }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* takeEvent((event) => event.type === "turn.terminal"); + + fake.setCompacting(true); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + // The dialog proves the pump observed compaction_start before the prompt. + yield* fake.emit({ + type: "extension_ui_request", + id: "held-prompt-dialog", + method: "confirm", + title: "Continue?", + }); + yield* takeEvent((event) => event.type === "runtime_request.updated"); + yield* startTurn(runtime, providerThread, "default", [], "Held prompt", undefined, 2); + // Writes go out in order, so this round trip flushes any prompt sent before it. + yield* runtime.readThreadSnapshot({ providerThread }); + assert.isFalse( + fake + .allRequests() + .some( + (request) => request["type"] === "prompt" && request["message"] === "Held prompt", + ), + ); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.status === "running" && + event.providerTurn.ordinal === 2, + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + + const interruptedAt = fake.allRequests().length; + if (restart) { + fake.deferNextLifecycle("abort"); + const stop = yield* Effect.forkChild( + runtime.interruptTurn({ + providerThread, + providerTurnId: providerTurnId!, + requestRuntimeRestart: true, + }), + ); + const abort = yield* fake.takeRequest("abort"); + // The abort ends compaction before Pi exits; the stopped turn's prompt stays held. + fake.setCompacting(false); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: null, + aborted: true, + }); + yield* fake.emit({ + type: "extension_ui_request", + id: "after-compaction-dialog", + method: "confirm", + title: "Continue?", + }); + yield* takeEvent((event) => event.type === "runtime_request.updated"); + yield* fake.emit({ type: "response", id: abort["id"], command: "abort", success: true }); + yield* Fiber.join(stop); + } else { + yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); + assert.isFalse(fake.allRequests().some((request) => request["type"] === "abort")); + } + yield* fake.closeStdout; + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + assert.isFalse( + fake + .allRequests() + .slice(interruptedAt) + .some((request) => request["type"] === "prompt"), + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("delivers a steer sent during in-turn compaction once it ends", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + yield* fake.emit({ type: "agent_start" }); + fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + fake.setCompacting(true); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: providerTurnId!, + message: { + messageId: "message:thread-pi-test:steer-during-compaction" as never, + text: "Steer during compaction", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + fake.setCompacting(false); + const compactionEnded = fake.allRequests().length; + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, + aborted: false, + willRetry: false, + }); + const delivered = yield* fake.takeRequest("prompt"); + assert.equal(delivered["message"], "Steer during compaction"); + yield* fake.emit({ type: "agent_start" }); + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal( + fake + .allRequests() + .slice(compactionEnded) + .filter( + (request) => + request["type"] === "prompt" && request["message"] === "Steer during compaction", + ).length, + 1, + ); + assert.isTrue( + terminal.type === "turn.terminal" && terminal.status === "completed", + `turn ended ${terminal.type === "turn.terminal" ? terminal.status : terminal.type}`, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("keeps working after a settle probe fails before detached compaction", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index a32a5759ba707..b7045bcd64360 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -398,6 +398,15 @@ function piApprovalRequestKind(title: string): "command" | "file-change" { : "command"; } +/** Pi 1.4.0 rejects prompts with this when they race a compaction it already started. */ +const PI_COMPACTING_PROMPT_REJECTION = "Cannot submit a prompt while compaction is in progress"; + +interface PiPromptSend { + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly kind: "turn_start" | "steer"; + readonly record: PiRpcRecord; +} + interface PiThreadState { providerThread: OrchestrationV2ProviderThread; activeTurn: ActivePiTurn | null; @@ -560,14 +569,30 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const modelContextWindows = new Map(); let modelsDiscovered = false; // Prompt ACKs lack IDs; retain session send order and ownership so late ACKs cannot affect new turns. - const pendingPromptResponses: Array<{ - readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; - readonly kind: "turn_start" | "steer"; - }> = []; + const pendingPromptResponses: Array = []; const pendingCompactResponses: Array<{ readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; readonly kind: "turn_start" | "steer"; }> = []; + // Pi rejects every prompt while it compacts, including compaction an + // extension starts after the turn settled. Hold prompts until it ends. + let nativeCompacting = false; + const heldPrompts: Array = []; + // Prompts sent before the pump observed compaction_start are rejected + // after it; they precede every prompt held since. + let rejectedHeldCount = 0; + + const sendPrompt = Effect.fnUntraced(function* (prompt: PiPromptSend) { + if (nativeCompacting) { + heldPrompts.push(prompt); + return; + } + yield* connection.send(prompt.record); + pendingPromptResponses.push(prompt); + }); + // A just-released prompt has not started its run yet, so a settle probe + // racing it would see Pi idle. Its ack re-arms the probe instead. + let releasedPromptTurnId: OrchestrationV2ProviderTurn["id"] | null = null; const compactRpcRecord = (command: PiCompactCommand): PiRpcRecord => command.customInstructions === undefined @@ -1835,7 +1860,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (result === null || result === undefined) { if (event["aborted"] === true) { yield* emitCompaction(turn, compaction, "cancelled"); - if (turn.settleWhenIdle || !turn.sawAgentActivity) { + if ( + (turn.settleWhenIdle || !turn.sawAgentActivity) && + releasedPromptTurnId !== turn.providerTurn.id + ) { yield* scheduleSettleProbe(turn, turn.settleWhenIdle); } return; @@ -1845,7 +1873,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* emitCompaction(turn, compaction, "failed", { summary: errorMessage.slice(0, 1_000), }); - if (turn.settleWhenIdle || !turn.sawAgentActivity) { + if ( + (turn.settleWhenIdle || !turn.sawAgentActivity) && + releasedPromptTurnId !== turn.providerTurn.id + ) { yield* scheduleSettleProbe(turn, turn.settleWhenIdle); } return; @@ -1862,7 +1893,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ...(beforeTokenCount === undefined ? {} : { beforeTokenCount }), ...(afterTokenCount === undefined ? {} : { afterTokenCount }), }); - if (turn.settleWhenIdle || !turn.sawAgentActivity) { + if ( + (turn.settleWhenIdle || !turn.sawAgentActivity) && + releasedPromptTurnId !== turn.providerTurn.id + ) { yield* scheduleSettleProbe(turn, turn.settleWhenIdle); } return; @@ -2012,6 +2046,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const pendingPrompt = command === "prompt" ? pendingPromptResponses.shift() : undefined; const responseTurn = pendingPrompt?.providerTurnId === turn?.providerTurn.id ? turn : null; + if ( + event["success"] === false && + nativeCompacting && + recordString(event, "error")?.startsWith(PI_COMPACTING_PROMPT_REJECTION) === true && + pendingPrompt !== undefined && + responseTurn !== null + ) { + heldPrompts.splice(rejectedHeldCount++, 0, pendingPrompt); + return; + } + // Settlement is not probed while a steer awaits its ack (see + // compaction_end); once Pi answers, the run it may have started is + // visible to get_state. + if (pendingPrompt?.kind === "steer" && responseTurn?.settleWhenIdle === true) { + yield* scheduleSettleProbe(responseTurn, true); + } if (event["success"] === true) { // Command-only prompts emit no agent_settled; probe idle and re-queue behind preceding native events. if ( @@ -2123,7 +2173,33 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* Effect.gen(function* () { while (true) { const event = yield* Queue.take(connection.events); - yield* sessionEventPermit.withPermits(1)(handleSessionEvent(event)); + yield* sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + // Tracked here rather than in handleSessionEvent, which also + // replays buffered wake events after they were observed. + if (event["type"] === "compaction_start") nativeCompacting = true; + if (event["type"] === "compaction_end") { + nativeCompacting = false; + // Send before the handler can probe settlement, so Pi sees the + // prompt first. Prompts whose turn ended or is stopping are dropped. + rejectedHeldCount = 0; + for (const held of heldPrompts.splice(0)) { + const turn = threadState?.activeTurn; + if ( + turn?.providerTurn.id !== held.providerTurnId || + turn.interrupted || + stopRequested + ) + continue; + yield* sendPrompt(held); + releasedPromptTurnId = held.providerTurnId; + } + } + yield* handleSessionEvent(event).pipe( + Effect.ensuring(Effect.sync(() => (releasedPromptTurnId = null))), + ); + }), + ); } }).pipe( Effect.catchCause((cause) => @@ -2558,15 +2634,15 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( kind: "turn_start", }); } else if (payload !== null) { - yield* connection.send({ - type: "prompt", - message: payload.message, - streamingBehavior: "steer", - ...(payload.images.length === 0 ? {} : { images: payload.images }), - }); - pendingPromptResponses.push({ + yield* sendPrompt({ providerTurnId: providerTurn.id, kind: "turn_start", + record: { + type: "prompt", + message: payload.message, + streamingBehavior: "steer", + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }, }); } yield* emit({ @@ -2652,15 +2728,15 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( kind: "steer", }); } else if (payload !== null) { - yield* connection.send({ - type: "prompt", - message: payload.message, - streamingBehavior: "steer", - ...(payload.images.length === 0 ? {} : { images: payload.images }), - }); - pendingPromptResponses.push({ + yield* sendPrompt({ providerTurnId: turn.providerTurn.id, kind: "steer", + record: { + type: "prompt", + message: payload.message, + streamingBehavior: "steer", + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }, }); } turn.settleProbeGeneration += 1; @@ -2696,7 +2772,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( interruptInput.requestRuntimeRestart === true || turn.settleWhenIdle || turn.activeCompaction !== null || - turn.manualCompactInFlight + turn.manualCompactInFlight || + nativeCompacting ) { // Generic abort misses manual compaction; terminate so Stop covers /compact and recovery. stopRequested = true; diff --git a/packages/secret-provider-core/src/server/SecretResolver.test.ts b/packages/secret-provider-core/src/server/SecretResolver.test.ts index 573fcfa4f655a..d654a1c942267 100644 --- a/packages/secret-provider-core/src/server/SecretResolver.test.ts +++ b/packages/secret-provider-core/src/server/SecretResolver.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { @@ -63,7 +64,7 @@ const resolverLayer = ( SecretResolver.layer({ drivers: [driver], secretProviders: Effect.sync(secretProviders), - }).pipe(Layer.provide(NodeServices.layer)); + }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); describe("SecretResolver", () => { it.effect("reads a reference once and holds it", () => { diff --git a/packages/secret-provider-core/src/server/driver.ts b/packages/secret-provider-core/src/server/driver.ts index 85deb93d8938b..75241c96da83b 100644 --- a/packages/secret-provider-core/src/server/driver.ts +++ b/packages/secret-provider-core/src/server/driver.ts @@ -1,5 +1,5 @@ import type { SecretProviderKind } from "@t3tools/contracts"; -import type * as Crypto from "effect/Crypto"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; @@ -11,7 +11,7 @@ import type { SecretProviderDefinition } from "../shared/definition.ts"; export type SecretProviderDriverServices = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem - | Crypto.Crypto; + | SecureRandom.SecureRandom; export interface SecretProviderDriver { readonly kind: SecretProviderKind; diff --git a/packages/secret-provider-onepassword/src/server/driver.ts b/packages/secret-provider-onepassword/src/server/driver.ts index abf9d6da5ed88..8d309bbbd7366 100644 --- a/packages/secret-provider-onepassword/src/server/driver.ts +++ b/packages/secret-provider-onepassword/src/server/driver.ts @@ -1,7 +1,7 @@ import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; import { defineSecretProviderDriver } from "@t3tools/secret-provider-core/server/driver"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -51,7 +51,7 @@ const readTogether = Effect.fn("OnePasswordDriver.readTogether")(function* ( references: ReadonlyArray, ) { const fileSystem = yield* FileSystem.FileSystem; - const separator = `__t3-secret-${yield* (yield* Crypto.Crypto).randomUUIDv4()}__`; + const separator = `__t3-secret-${yield* (yield* SecureRandom.SecureRandom).uuidV4()}__`; const templatePath = yield* fileSystem.makeTempFileScoped({ prefix: "t3code-op-inject-" }); yield* fileSystem.writeFileString( templatePath, diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index 40aa4f7743be6..a12eed6a83995 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -59,6 +59,10 @@ describe("labels and reference links", () => { collectComposerContextReferences(`[${"x".repeat(300)}](t3-context://v1/file/ctx_1)`)[0] ?.label, ).toHaveLength(200); + expect( + collectComposerContextReferences(`[${"x".repeat(199)}😀tail](t3-context://v1/file/ctx_1)`)[0] + ?.label, + ).toBe("x".repeat(199)); }); it("collects occurrences in document order with offsets, sharing a payload", () => { @@ -221,6 +225,29 @@ describe("provider projection", () => { expect(project("a\n")).toContain("3 | a\n4 | \n"); }); + it("keeps a review excerpt's trailing blank line and whitespace", () => { + const projected = projectComposerContextForProvider({ + text: "[Comment](t3-context://v1/review-comment/ctx_r)", + records: [ + { + version: 1, + contextId: ctx("ctx_r"), + kind: "review-comment", + label: "Comment", + sectionId: "turn:1", + sectionTitle: "Turn 1", + filePath: "src/app.ts", + startIndex: 1, + endIndex: 2, + rangeLabel: "2 to 3", + text: "Insert here.", + diff: "@@ -3,1 +2,2 @@\n+two\t\n \n", + }, + ], + }); + expect(projected).toContain("diff:\n @@ -3,1 +2,2 @@\n +two\t\n \n"); + }); + it("emits no terminal lines when the captured range is inverted", () => { const projected = projectComposerContextForProvider({ text: "[log](t3-context://v1/terminal/ctx_t)", diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index c54636c537335..1a5896e7d77b8 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -69,11 +69,15 @@ export function parseComposerContextHref( } export function sanitizeComposerContextLabel(label: string, kind: ComposerContextKind): string { - const cleaned = label + const normalized = label .replace(/[[\]\\\r\n]/g, " ") .replace(/\s+/g, " ") - .trim() - .slice(0, COMPOSER_CONTEXT_LABEL_MAX_CHARS); + .trim(); + let end = COMPOSER_CONTEXT_LABEL_MAX_CHARS; + const before = normalized.charCodeAt(end - 1); + const after = normalized.charCodeAt(end); + if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end--; + const cleaned = normalized.slice(0, end); return cleaned.length > 0 ? cleaned : kind; } @@ -291,7 +295,7 @@ function formatComposerContextProviderPayload(record: KnownComposerContextRecord ]; if (record.text.trim()) lines.push("comment:", indent(record.text.trim())); if (record.diff.trim()) { - lines.push(`${record.fenceLanguage ?? "diff"}:`, indent(record.diff.trimEnd())); + lines.push(`${record.fenceLanguage ?? "diff"}:`, indent(record.diff.replace(/\n+$/, ""))); } return lines.join("\n"); } diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index 3190afbc7928a..9531354db1939 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -1242,6 +1242,7 @@ const make = Effect.fn("makeWithDatabase")(function* ( ); const client = yield* Client.make({ + driver: "node:sqlite", acquirer: transactionAcquirer, borrower, compiler, diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 9b0ccd8826213..1d6348852722c 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -106,6 +106,21 @@ describe("workspaceRelativeFilePath", () => { ["/repo/project/../other/notes.md", "/repo/project", null], ["C:\\repo\\project\\src\\..\\..\\notes.md", "C:\\repo\\project", null], ["/repo/project/docs/a/../b.md", "/repo/project", "docs/a/../b.md"], + ["/repo/a\\b.txt", "/repo", "a\\b.txt"], + ["/repo/a/b.txt", "/repo", "a/b.txt"], + ["/repo/a\\", "/repo", "a\\"], + ["/repo/..\\notes.md", "/repo", "..\\notes.md"], + ["/repo/a\\/../../notes.md", "/repo", null], + ["/repo\\project/a.txt", "/repo\\project/", "a.txt"], + ["/repo/project/a.txt", "/repo\\project", null], + ["/repo\\project/a.txt", "/repo/project", null], + ["C:\\Repo\\src\\main.ts", "c:/repo/", "src/main.ts"], + ["/C:/Repo/src/main.ts", "c:\\repo", "src/main.ts"], + ["C:\\Repo\\src\\main.ts", "/c:/repo", "src/main.ts"], + ["\\\\SERVER\\Share\\Repo\\src\\main.ts", "\\\\server\\share\\repo", "src/main.ts"], + ["\\\\SERVER\\Share\\Repo\\src\\main.ts", "//server/share/repo", "src/main.ts"], + ["//server/share/a\\b.txt", "//server/share", "a\\b.txt"], + ["\\\\server\\share\\repo\\..\\notes.md", "\\\\server\\share\\repo", null], ])("relates %s to %s", (path, workspaceRoot, relativePath) => { expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index a7cce7cb88f09..e2c3cd3117aa2 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -95,11 +95,17 @@ export function workspaceRelativeFilePath( workspaceRoot: string | null | undefined, ): string | null { if (!workspaceRoot) return null; - const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")); - const normalizedRoot = stripSlashPrefixedWindowsDrive( - workspaceRoot.replaceAll("\\", "/"), + const caseInsensitive = + isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)) || + (isNetworkPath(workspaceRoot) && isUncPath(path)); + const normalizedPath = caseInsensitive + ? stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")) + : path; + const normalizedRoot = ( + caseInsensitive + ? stripSlashPrefixedWindowsDrive(workspaceRoot.replaceAll("\\", "/")) + : workspaceRoot ).replace(/\/+$/, ""); - const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return "."; diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index 23f4b4390c74f..231b141bf036f 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -149,6 +149,14 @@ describe("resolveSearchUrlTemplate", () => { }); describe("normalizePreviewUrl", () => { + it("tells agents how to open a blank tab and which URLs are accepted", () => { + expect(() => normalizePreviewUrl("about:blank")).toThrow( + /preview_open, omit url when creating a new tab[^.]*blank tab/i, + ); + expect(() => normalizePreviewUrl("about:blank")).toThrow(/http\(s\)/i); + expect(() => normalizePreviewUrl("about:blank")).toThrow(/schemeless host/i); + }); + it("rejects unparseable input without retaining credentials or tokens", () => { const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; try { diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 30a6bdc8b1cbb..f279c07f73866 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -66,7 +66,7 @@ export class PreviewUrlNormalizationError extends Schema.TaggedError }); type RelayClientTestServices = - | Layer.Success | typeof NodeServices.layer> + | Layer.Success< + ReturnType | typeof NodeServices.layer | typeof NodeCrypto.layer + > | HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner; @@ -141,6 +144,7 @@ const testReleaseAsset = { const layerInstallRuntime = (platform: NodeJS.Platform) => Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(testBinary), layerSpawner([]), layerHostRuntime({ PATH: "" }, platform), @@ -178,6 +182,7 @@ describe("RelayClient", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(new Uint8Array()), layerSpawner([]), layerHostRuntime({ PATH: "" }), @@ -338,6 +343,7 @@ describe("RelayClient", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(new Uint8Array()), layerSpawner([]), layerHostRuntime({ PATH: "" }), @@ -396,6 +402,7 @@ describe("RelayClient", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(new TextEncoder().encode("test-binary")), layerSpawner([]), layerHostRuntime({ PATH: "" }), @@ -455,6 +462,7 @@ describe("RelayClient", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(new TextEncoder().encode("test-cloudflared-binary")), layerSpawner([]), layerHostRuntime(), @@ -491,6 +499,7 @@ describe("RelayClient", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(new TextEncoder().encode("tampered")), layerSpawner([]), layerHostRuntime(), @@ -526,6 +535,7 @@ describe("RelayClient", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(bytes), layerSpawner(commands), layerHostRuntime(), @@ -598,6 +608,7 @@ describe("RelayClient", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(new Uint8Array()), layerSpawner([]), layerHostRuntime(configEnv, platform), @@ -622,6 +633,7 @@ describe("RelayClient", () => { Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, layerHttpClient(new Uint8Array()), layerSpawner([], options.versions), layerHostRuntime({ PATH: "" }), diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 4b277a1196e03..ea461340681b3 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -8,7 +8,8 @@ import type { } from "@t3tools/contracts"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -249,12 +250,14 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function RelayClientShape, never, | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | SecureRandom.SecureRandom + | Digest.Digest | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path > { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; const httpClient = yield* HttpClient.HttpClient; const path = yield* Path.Path; @@ -463,7 +466,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ), ); yield* report("verifying"); - const checksum = yield* crypto.digest("SHA-256", bytes).pipe( + const checksum = yield* digest.digest(Digest.sha256, bytes).pipe( Effect.mapError( (cause) => new RelayClientInstallError({ @@ -497,7 +500,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( lockPath: string, ) { - const owner = `${process.pid}-${yield* crypto.randomUUIDv4()}`; + const owner = `${process.pid}-${yield* random.uuidV4()}`; const candidate = yield* Effect.acquireRelease( fileSystem.makeTempDirectory({ directory: path.dirname(lockPath), prefix: ".lock-" }), (directory) => @@ -634,7 +637,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }); } - const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4()}.tmp`; + const stagedPath = `${managedPath}.${yield* random.uuidV4()}.tmp`; yield* report("activating"); yield* renameWhenUnlocked(executablePath, stagedPath).pipe( wrapInstallFailure("write_failed", "Could not stage the relay client."), diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index 82977d045acf8..876373e8d9953 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -23,6 +23,9 @@ describe("resolveT3McpToolPresentation", () => { "mcp__t3_code__", "mcp__t3code__", "T3-code.", + "t3code.", + "t3-code-direct.", + "mcp__t3_code_direct__", "t3_code/", "t3code:", "mcp_t3-code_", diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 3312daadbe0fb..f7069a0e1c012 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -87,7 +87,14 @@ function tool( return { displayName, labels, icon, summaryAction }; } -const T3_MCP_SERVER_ALIASES = new Set(["t3-code", "t3_code", "t3code"]); +// Codex registers "t3-code-direct" for T3 tools kept out of Code Mode. +const T3_MCP_SERVER_ALIASES = new Set([ + "t3-code", + "t3_code", + "t3code", + "t3-code-direct", + "t3_code_direct", +]); const T3_MCP_TOOLS: Readonly> = { link_pull_request: tool( @@ -391,9 +398,10 @@ function resolveT3McpToolName(value: string): string | null { : null; } - const namespaceMatch = /^(?t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*)(?.+)$/i.exec( - label, - ); + const namespaceMatch = + /^(?t3[-_]code[-_]direct|t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*)(?.+)$/i.exec( + label, + ); if (namespaceMatch?.groups) { return namespaceMatch.groups.tool ?? null; } diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index b58c2e6a8d878..828b652077f62 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -271,6 +271,14 @@ describe("mergeUsage", () => { ); expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]); } + + const filtered = environment("new", { + ...partial.summary, + modelFilter: { exclude: [{ model: "claude-fable-5" }] }, + }); + const merged = mergeUsage([complete, filtered], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.sessions).toBe(1); }); it("retains a complete cell when a larger partial cell may have skipped old records", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 994ae01d6ffec..6c3e9e84ae21d 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -178,9 +178,12 @@ function claimSources(environments: readonly EnvironmentUsage[]): { if (source.status !== "partial") continue; const key = fingerprintKey(source.fingerprint); const owner = ownerScanByFingerprint.get(key); + // A scan under another model filter counts different records, so its cells are not missing ones. if ( owner?.source.status !== "ok" || - Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt) + Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt) || + JSON.stringify(environment.summary.modelFilter) !== + JSON.stringify(owner.environment.summary.modelFilter) ) { continue; } diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 9469c9d4d4e8b..12885fd2a048f 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -49,6 +49,7 @@ export class PullRequestProviderError extends Schema.TaggedError { const createdAt = "2026-10-10T19:52:40Z"; @@ -34,3 +40,24 @@ describe("forgejoComment", () => { assert.strictEqual(mapped.editedAt, "2026-10-10T20:04:50.000Z"); }); }); + +describe("forgejoReview", () => { + it.each([ + ["REQUEST_CHANGES", false, "CHANGES_REQUESTED"], + ["REQUEST_CHANGES", true, "DISMISSED"], + ["APPROVED", true, "DISMISSED"], + ])("reads %s with dismissed %s as %s", (state, dismissed, reviewState) => { + const mapped = forgejoReview( + decodeForgejoReview({ + id: 8, + body: "", + user: { login: "maria" }, + state, + dismissed, + submitted_at: "2026-10-10T19:52:40Z", + comments_count: 0, + }), + ); + assert.strictEqual(mapped.reviewState, reviewState); + }); +}); diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts index 2d9c381eb14c9..7a7daeac64f00 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts @@ -77,6 +77,7 @@ export const ForgejoReview = Schema.Struct({ body: Schema.String, user: Schema.NullOr(ForgejoUser), state: Schema.String, + dismissed: Schema.optional(Schema.Boolean), submitted_at: Schema.String, html_url: Schema.optional(Schema.String), comments_count: Schema.Int, @@ -173,11 +174,13 @@ export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestCom url: review.html_url || null, path: null, reviewState: - review.state === "REQUEST_CHANGES" - ? "CHANGES_REQUESTED" - : review.state === "COMMENT" - ? "COMMENTED" - : review.state, + review.dismissed === true + ? "DISMISSED" + : review.state === "REQUEST_CHANGES" + ? "CHANGES_REQUESTED" + : review.state === "COMMENT" + ? "COMMENTED" + : review.state, }; } diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index 113a388e853bb..cb2bf219c0b5e 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -1103,6 +1103,8 @@ describe("GitCafePullRequestProvider", () => { const detail = yield* provider.getChangeRequest(target); assert.strictEqual(detail.state, "merged"); assert.strictEqual(detail.mergedAt, mergedAt); + const preview = yield* provider.getChangeRequestPreview!(target); + assert.strictEqual(preview.state, "merged"); }).pipe(Effect.provide(server.layer)); }); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index e87a4e154d69b..7f8b4bab29da6 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -897,7 +897,7 @@ export const make = Effect.gen(function* () { ), getChangeRequestPreview: (input) => readPull("getChangeRequestPreview", input).pipe( - Effect.map((pull) => Json.toChangeRequest(pull, input)), + Effect.map((pull) => ({ ...Json.toChangeRequest(pull, input), ...settledMerge(pull) })), ), getChangeRequestSummary: Effect.fn("GitCafePullRequestProvider.getChangeRequestSummary")( function* (input) { diff --git a/packages/source-control-github/scripts/measure-pr-preview.ts b/packages/source-control-github/scripts/measure-pr-preview.ts index 6f03586958cca..749fd9d53b9f4 100644 --- a/packages/source-control-github/scripts/measure-pr-preview.ts +++ b/packages/source-control-github/scripts/measure-pr-preview.ts @@ -2,6 +2,7 @@ // Real GitHub reads share repository-permission caches, excluding browser and service caches. // HTTP client request counts are exact; GraphQL cost uses changes in `x-ratelimit-used`, // which are approximate when other clients spend the same token's quota. +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; @@ -97,7 +98,7 @@ const sourceControlHost = Layer.unwrap( }, }); }), -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(GitHubAppAccess.layer), diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index ed6a16f8e298d..7a927f44dbaf5 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -1,7 +1,7 @@ import { normalizeHost } from "@t3tools/source-control-core/server/pullRequestUtils"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -16,7 +16,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import { readSourceControlHostSettings } from "@t3tools/source-control-core/shared/definition"; import { isMissingExecutable } from "@t3tools/source-control-core/server/discovery"; -import type { ServerSettings } from "@t3tools/contracts"; +import { settingsSecretLiteral, type ServerSettings } from "@t3tools/contracts"; import * as GitHubClient from "../shared/definition.ts"; @@ -135,12 +135,12 @@ export function environmentToken( export const make = Effect.gen(function* () { const sourceControlHost = yield* SourceControlHost.SourceControlHost; const process = sourceControlHost.process; - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const environment = yield* HostProcess.Environment; const workingDirectory = yield* HostProcess.WorkingDirectory; const fingerprintOf = (host: string, token: string) => - crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( + crypto.digest(Digest.sha256, new TextEncoder().encode(token)).pipe( Effect.map((digest) => `${host}:${Hex.encode(digest)}`), Effect.orDie, ); @@ -192,8 +192,10 @@ export const make = Effect.gen(function* () { ); const savedToken = (host: string) => - sourceControlHost.settings.get.pipe( - Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null), + sourceControlHost.settings.getWithSecrets(GitHubClient.definition.kind).pipe( + Effect.map( + (settings) => settingsSecretLiteral(readSettings(settings).tokens[host]).trim() || null, + ), Effect.orElseSucceed(() => null), ); diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index 6e602e1025d32..bb4b8ba0e0179 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -118,6 +118,44 @@ it.effect("polls checks without loading pull request details and keeps fork appr }), ); +it.effect.each([ + { status: 403, githubErrorTypes: [], reason: "forbidden" }, + { status: 503, githubErrorTypes: [], reason: "failed" }, + { status: 200, githubErrorTypes: ["FORBIDDEN"], reason: "forbidden" }, + { status: 200, githubErrorTypes: ["INTERNAL"], reason: "failed" }, + { status: 200, githubErrorTypes: ["FORBIDDEN", "NOT_FOUND"], reason: "forbidden" }, + { status: 200, githubErrorTypes: ["FORBIDDEN", "INTERNAL"], reason: "failed" }, + { status: 200, githubErrorTypes: ["FORBIDDEN", null], reason: "failed" }, +])("classifies GitHub response $status as $reason", ({ status, githubErrorTypes, reason }) => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + getPullRequestDetail: () => + Effect.fail( + new GitHubRequestPolicy.GitHubApiResponseError({ + host: "github.com", + operation: "detail", + status, + githubErrorTypes, + }), + ), + }), + ), + ); + const error = yield* provider + .getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe(Effect.flip); + expect(error.reason).toBe(reason); + }), +); + it.effect("advertises standalone branch deletion for source repository writers", () => Effect.gen(function* () { let action: string | undefined; diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index 76e9acc88eaf3..ee5d9273717a5 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -134,6 +134,15 @@ function gitHubProviderFailure( ...(error.remaining === undefined ? {} : { remaining: error.remaining }), ...(error.limit === undefined ? {} : { limit: error.limit }), }; + case "GitHubApiResponseError": + return { + reason: + error.status === 403 || + (error.githubErrorTypes?.includes("FORBIDDEN") && + error.githubErrorTypes.every((type) => type === "FORBIDDEN" || type === "NOT_FOUND")) + ? "forbidden" + : "failed", + }; case "GitHubApiNotFoundError": return error.credential === undefined || error.credential === "app" ? { reason: "not-found" } diff --git a/packages/source-control-github/src/server/GitHubRequestPolicy.ts b/packages/source-control-github/src/server/GitHubRequestPolicy.ts index 4a543ff82a383..2585a65d32e1a 100644 --- a/packages/source-control-github/src/server/GitHubRequestPolicy.ts +++ b/packages/source-control-github/src/server/GitHubRequestPolicy.ts @@ -92,6 +92,7 @@ export class GitHubApiResponseError extends Schema.TaggedError current.sourceControlHosts[GitHubClient.definition.kind]), Effect.orElseSucceed(() => undefined), ), @@ -237,7 +237,8 @@ export const makeDiscovery = Effect.gen(function* () { }, spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); - const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; + const token = settings.tokens["github.com"]; + const savedToken = settingsSecretLiteral(token).trim() !== ""; const signedIn = appSignIn.enabled && !savedToken && (settings.signIns["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); diff --git a/packages/source-control-github/src/server/gitHubDiscovery.test.ts b/packages/source-control-github/src/server/gitHubDiscovery.test.ts index cd8a838b23723..b108d71f75584 100644 --- a/packages/source-control-github/src/server/gitHubDiscovery.test.ts +++ b/packages/source-control-github/src/server/gitHubDiscovery.test.ts @@ -1,10 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { ServerSettings, SourceControlProviderKind } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; @@ -79,3 +83,79 @@ it.effect("asks for no credential when the identity resolver left the base URL e assert.deepStrictEqual(hosts, []); }), ); + +const decodeSettings = Schema.decodeSync(ServerSettings); +const referencedTokenSettings = decodeSettings({ + sourceControlHosts: { + github: { tokens: { "github.com": { provider: "vault", reference: "op://v/gh" } } }, + }, +}); + +it.effect.each([ + { name: "readable", token: "resolved-token", source: "the token saved in Settings" }, + { name: "unreadable", token: "", source: "GH_TOKEN from the server environment" }, +])("checks the $name GitHub token reference against the API", (scenario) => + Effect.gen(function* () { + const paths: Array = []; + const discovery = yield* GitHubSourceControlProvider.makeDiscovery.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(GitHubRest.GitHubRest)({ + rest: (input) => { + paths.push(input.path); + return Effect.succeed({ + status: 200, + headers: {}, + body: JSON.stringify({ login: "octocat" }), + truncated: false, + invalidUtf8: false, + }); + }, + }), + Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ enabled: false }), + Layer.effect( + SourceControlHost.SourceControlHost, + Effect.gen(function* () { + const host = yield* SourceControlHost.SourceControlHost; + return { + ...host, + settings: { + ...host.settings, + getWithSecrets: () => + Effect.succeed( + decodeSettings({ + sourceControlHosts: { + github: { tokens: { "github.com": scenario.token } }, + }, + }), + ), + }, + }; + }), + ).pipe( + Layer.provide( + TestSourceControlHost.layer({ + settings: referencedTokenSettings, + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput("", { + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ), + }, + }), + ), + ), + ), + ), + Effect.provideService(HostProcess.Environment, { GH_TOKEN: "environment-token" }), + ); + if (discovery.type !== "managed-cli") return assert.fail("expected a managed CLI"); + const item = yield* discovery.probe("/repo"); + assert.deepStrictEqual(paths, ["user"]); + assert.strictEqual(item.auth.status, "authenticated"); + assert.deepStrictEqual(item.auth.account, Option.some("octocat")); + assert.include(Option.getOrThrow(item.auth.detail), scenario.source); + }), +); diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts index e14f4df359c51..1eb7a3b05097a 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts @@ -361,6 +361,7 @@ describe("call", () => { errors: [ { type: "NOT_FOUND", message: "Could not resolve" }, { type: "FORBIDDEN", message: "Resource not accessible" }, + { message: "Unknown error" }, ], }), ); @@ -369,7 +370,8 @@ describe("call", () => { expect(error).toMatchObject({ _tag: "GitHubApiResponseError", status: 200, - githubErrors: ["Could not resolve", "Resource not accessible"], + githubErrors: ["Could not resolve", "Resource not accessible", "Unknown error"], + githubErrorTypes: ["NOT_FOUND", "FORBIDDEN", null], }); }).pipe(Effect.provide(layer)); }, diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.ts index 225f3c0d97e6c..c8899d99fcd49 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -265,6 +265,9 @@ const fromClientError = ( ...context, status: reason.status ?? 200, githubErrors: reason.errors.map((graphQlError) => graphQlError.message), + githubErrorTypes: reason.errors.map((graphQlError) => + typeof graphQlError["type"] === "string" ? graphQlError["type"] : null, + ), }); }; diff --git a/packages/source-control-gitlab/src/shared/definition.test.ts b/packages/source-control-gitlab/src/shared/definition.test.ts index 7f9e94fa90887..f57c6274deeb8 100644 --- a/packages/source-control-gitlab/src/shared/definition.test.ts +++ b/packages/source-control-gitlab/src/shared/definition.test.ts @@ -61,6 +61,22 @@ describe("GitLab change request URLs", () => { number: 4, }), ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); + expect( + definition.changeRequestUrl({ + host: "gitlab.internal", + repository: "team/app", + number: 5, + remoteUrl: "http://gitlab.internal:8080/team/app.git", + }), + ).toBe("http://gitlab.internal:8080/team/app/-/merge_requests/5"); + expect( + definition.changeRequestUrl({ + host: "gitlab.internal", + repository: "team/app", + number: 5, + remoteUrl: "git@gitlab.internal:team/app.git", + }), + ).toBe("https://gitlab.internal/team/app/-/merge_requests/5"); expect( definition.changeRequestUrl({ host: "gitlab.com", diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 2d280f01c62f2..10972a3e772b8 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -8,6 +8,7 @@ import { parseChangeRequestPath, parseCommitPath, projectChangeRequestHost, + remoteWebOrigin, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; @@ -38,10 +39,10 @@ export const definition = defineSourceControl({ publishHost: () => "gitlab.com", newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number, webUrl }) => + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => webUrl ? changeRequestUrlBelow(webUrl, `-/merge_requests/${number}`) - : `https://${host}/${repository}/-/merge_requests/${number}`, + : `${remoteWebOrigin(host, remoteUrl)}/${repository}/-/merge_requests/${number}`, parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), commitUrl: (input) => buildCommitUrl(input, "-/commit"), parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index 9503e4d0606d5..9c6d5ed79ec06 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -51,7 +52,7 @@ describe("ssh command", () => { it.effect("passes an option-shaped alias to ssh as the destination", () => { const spawned: Array> = []; const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) spawned.push(command.args); + if (ChildProcess.isStandardCommand(command)) spawned.push(command.args.map(String)); return Effect.succeed(makeFailedProcess({ stdout: "", stderr: "bad hostname\n" })); }); return runSshCommand( @@ -64,7 +65,7 @@ describe("ssh command", () => { }), Effect.provide( Layer.merge( - NodeServices.layer, + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ), @@ -92,7 +93,7 @@ describe("ssh command", () => { }), "326264c4f08c8a0c", ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.live.each([ @@ -140,7 +141,7 @@ describe("ssh command", () => { ? `[truncated]\n${redacted.slice(-4_000)}` : `${redacted.slice(0, 4_000)}\n[truncated]`, ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.live("enables server-alive checks for long remote commands", () => @@ -160,7 +161,7 @@ describe("ssh command", () => { Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n' })), ); const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); - const layerProcess = Layer.mergeAll(NodeServices.layer, layerSpawner); + const layerProcess = Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, layerSpawner); return Effect.gen(function* () { const result = yield* Effect.result( diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 5011eea7cd7d9..eafda87d4ff70 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,6 +1,6 @@ import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -76,11 +76,12 @@ export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string export const remoteStateKey = Effect.fn("ssh/command.remoteStateKey")(function* ( target: DesktopSshEnvironmentTarget, -): Effect.fn.Return { - const crypto = yield* Crypto.Crypto; - const digest = yield* crypto - .digest("SHA-256", encoder.encode(targetConnectionKey(target))) - .pipe(Effect.orDie); +): Effect.fn.Return { + const digestService = yield* Digest.Digest; + const digest = yield* digestService.digest( + Digest.sha256, + encoder.encode(targetConnectionKey(target)), + ); return Hex.encode(digest).slice(0, 16); }); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index dc50e01694ec1..5a321c4436e13 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; import * as Deferred from "effect/Deferred"; @@ -77,7 +78,7 @@ const testNetService = NetService.NetService.of({ }); function commandArgs(command: ChildProcess.Command): ReadonlyArray { - return command._tag === "StandardCommand" ? command.args : []; + return command._tag === "StandardCommand" ? command.args.map(String) : []; } const ARCHIVE = { archiveVersion: "1.2.3-preview.20260911.4" } as const; @@ -142,6 +143,7 @@ describe("ssh tunnel scripts", () => { }); const layer = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), @@ -222,7 +224,10 @@ describe("ssh tunnel scripts", () => { }), ); const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); - const layerProcess = Layer.merge(NodeServices.layer, layerSpawner); + const layerProcess = Layer.merge( + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), + layerSpawner, + ); return Effect.gen(function* () { const result = yield* SshTunnel.launchOrReuseRemoteServer(target, undefined, ARCHIVE); @@ -252,7 +257,10 @@ describe("ssh tunnel scripts", () => { ), ); const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); - const layerProcess = Layer.merge(NodeServices.layer, layerSpawner); + const layerProcess = Layer.merge( + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), + layerSpawner, + ); return Effect.gen(function* () { const result = yield* SshTunnel.issueRemotePairingToken(target, undefined, ARCHIVE); assert.equal(result.credential, "LCL4R2TPHDKQ"); @@ -278,6 +286,7 @@ describe("ssh tunnel scripts", () => { ); const layer = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, hangingHttpClient), Layer.succeed(NetService.NetService, testNetService), @@ -335,6 +344,7 @@ describe("ssh tunnel scripts", () => { ); const layer = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, hangingHttpClient), Layer.succeed(NetService.NetService, testNetService), @@ -386,6 +396,7 @@ describe("ssh tunnel scripts", () => { ); const layer = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), @@ -428,6 +439,7 @@ describe("ssh tunnel scripts", () => { ); const layer = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), @@ -481,6 +493,7 @@ describe("ssh tunnel scripts", () => { ); const layer = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), @@ -584,6 +597,7 @@ describe("ssh tunnel scripts", () => { ); const layer = Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index efe0fe8cde202..88c7057de9c78 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -9,7 +9,7 @@ import * as NetService from "@t3tools/shared/Net"; import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -88,7 +88,7 @@ interface SshTunnelEntry { type SshEnvironmentEffectContext = | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient @@ -887,7 +887,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo ): Effect.fn.Return< { readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null }, SshCommandError | SshInvalidTargetError | SshLaunchError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Digest.Digest | FileSystem.FileSystem | Path.Path > { const stateKey = yield* remoteStateKey(target); yield* Effect.logInfo("ssh.remoteServer.launch.start", { @@ -949,7 +949,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT readonly credential: string; }, SshCommandError | SshInvalidTargetError | SshPairingError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Digest.Digest | FileSystem.FileSystem | Path.Path > { const stateKey = yield* remoteStateKey(target); yield* Effect.logDebug("ssh.remoteServer.pairingToken.start", { @@ -1001,7 +1001,7 @@ const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( ): Effect.fn.Return< void, SshCommandError | SshInvalidTargetError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Digest.Digest | FileSystem.FileSystem | Path.Path > { const stateKey = yield* remoteStateKey(target); yield* Effect.logInfo("ssh.remoteServer.stop.start", { @@ -1027,7 +1027,7 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")( ): Effect.fn.Return< string, SshCommandError | SshInvalidTargetError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Digest.Digest | FileSystem.FileSystem | Path.Path > { const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s"], @@ -1130,7 +1130,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: SshTunnelEntry, SshCommandError | SshInvalidTargetError | SshReadinessError, | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient @@ -1575,7 +1575,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ); tunnels.set(input.key, tunnelEntry); const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; - const cryptoService = yield* Crypto.Crypto; + const digestService = yield* Digest.Digest; const fileSystemService = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; yield* Scope.addFinalizer( @@ -1615,7 +1615,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma }, ).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService), - Effect.provideService(Crypto.Crypto, cryptoService), + Effect.provideService(Digest.Digest, digestService), Effect.provideService(FileSystem.FileSystem, fileSystemService), Effect.provideService(Path.Path, pathService), ); @@ -1807,7 +1807,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma }); /** - * @effect-expect-leaking ChildProcessSpawner | Crypto | FileSystem | HttpClient | NetService | Path | SshPasswordPrompt + * @effect-expect-leaking ChildProcessSpawner | Digest | FileSystem | HttpClient | NetService | Path | SshPasswordPrompt */ export class SshEnvironmentManager extends Context.Service< SshEnvironmentManager, diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index d1b90208443a3..96f6e7721ed66 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -338,7 +338,8 @@ describe("tailscale", () => { return Effect.gen(function* () { yield* readTailscaleStatus.pipe(Effect.provide(layer)); yield* readTailscaleSshEnabled.pipe(Effect.provide(layer)); - assert.deepEqual(killGraces, [500, 500]); + yield* ensureTailscaleServe({ localPort: 45_678 }).pipe(Effect.provide(layer)); + assert.deepEqual(killGraces, [500, 500, 500]); }); }); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index cb5748d21e7b0..4eb20f1150b6c 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -397,12 +397,14 @@ const runTailscaleCommand = Effect.fn("tailscale.runTailscaleCommand")(function* }; const timeout = Duration.fromInputUnsafe(timeoutInput); return yield* Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - Effect.catchDefect((cause) => - Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), - ), - ); + const child = yield* spawner + .spawn(ChildProcess.make(executable, args, { forceKillAfter: TAILSCALE_KILL_GRACE })) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), + ), + ); const [stderr, exitCode] = yield* Effect.all( [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" }, diff --git a/patches/alchemy@2.0.0-beta.84.patch b/patches/@alchemy.run__test@2.0.0-beta.85.patch similarity index 87% rename from patches/alchemy@2.0.0-beta.84.patch rename to patches/@alchemy.run__test@2.0.0-beta.85.patch index 469138523d0a3..0bd798963482f 100644 --- a/patches/alchemy@2.0.0-beta.84.patch +++ b/patches/@alchemy.run__test@2.0.0-beta.85.patch @@ -1,7 +1,7 @@ -diff --git a/lib/Test/Vitest.js b/lib/Test/Vitest.js +diff --git a/lib/Vitest.js b/lib/Vitest.js index 48cce71..ae67044 100644 ---- a/lib/Test/Vitest.js -+++ b/lib/Test/Vitest.js +--- a/lib/Vitest.js ++++ b/lib/Vitest.js @@ -3,7 +3,7 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; diff --git a/patches/@effect__vitest@4.0.5.patch b/patches/@effect__vitest@4.1.2.patch similarity index 88% rename from patches/@effect__vitest@4.0.5.patch rename to patches/@effect__vitest@4.1.2.patch index 754ab2eed6bcd..b5cc33ca3f821 100644 --- a/patches/@effect__vitest@4.0.5.patch +++ b/patches/@effect__vitest@4.1.2.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.d.ts b/dist/index.d.ts -index 845cb4c..24ede42 100644 +index 0db951b..d6b6a6b 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -10,14 +10,14 @@ import type * as Effect from "effect/Effect"; @@ -20,7 +20,7 @@ index 845cb4c..24ede42 100644 * Vitest test API without additional fixtures. * diff --git a/dist/index.js b/dist/index.js -index 178cc74..9289296 100644 +index d10d1e7..6839558 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5,7 +5,7 @@ import * as internal from "./internal/internal.js"; @@ -33,18 +33,18 @@ index 178cc74..9289296 100644 * Invokes the Effect Vitest equality-tester registration hook. * diff --git a/dist/internal/internal.js b/dist/internal/internal.js -index 7138319..4092c67 100644 +index 6c1b1c1..cdc3b26 100644 --- a/dist/internal/internal.js +++ b/dist/internal/internal.js -@@ -12,7 +12,7 @@ import * as Schedule from "effect/Schedule"; +@@ -13,7 +13,7 @@ import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; import * as TestConsole from "effect/testing/TestConsole"; -import * as V from "vitest"; +import * as V from "vite-plus/test"; const getCurrentSuite = V.TestRunner.getCurrentSuite; - const runPromise = /*#__PURE__*/Effect.fnUntraced(function* (effect, _ctx) { - const exit = yield* Effect.exit(effect); + const runPromise = (effect, ctx) => Effect.runPromise( + // Only the test is interruptible: once the signal aborts, the fiber stops diff --git a/dist/utils.js b/dist/utils.js index b97edbe..7dfd64a 100644 --- a/dist/utils.js @@ -59,7 +59,7 @@ index b97edbe..7dfd64a 100644 // Primitives // ---------------------------- diff --git a/package.json b/package.json -index b78f4d6..8c43672 100644 +index 068e75e..d14a80e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ @@ -67,11 +67,11 @@ index b78f4d6..8c43672 100644 }, "peerDependencies": { - "vitest": ">=5.0.0 <6.0.0", - "effect": "^4.0.5" + "effect": "^4.1.2" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts -index 9d65132..4aadda8 100644 +index cb3bf6f..8024206 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ import type * as Effect from "effect/Effect" @@ -93,10 +93,10 @@ index 9d65132..4aadda8 100644 /** * Vitest test API without additional fixtures. diff --git a/src/internal/internal.ts b/src/internal/internal.ts -index b43385b..4da79fd 100644 +index e6ab75a..3b938a4 100644 --- a/src/internal/internal.ts +++ b/src/internal/internal.ts -@@ -14,7 +14,7 @@ import type * as Schema from "effect/Schema" +@@ -15,7 +15,7 @@ import type * as Schema from "effect/Schema" import * as Scope from "effect/Scope" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6111c5a59103a..159af8b0151ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,29 +165,32 @@ settings: catalogs: default: '@alchemy.run/axiom': - specifier: 2.0.0-beta.84 - version: 2.0.0-beta.84 + specifier: 2.0.0-beta.85 + version: 2.0.0-beta.85 '@alchemy.run/cloudflare': - specifier: 2.0.0-beta.84 - version: 2.0.0-beta.84 + specifier: 2.0.0-beta.85 + version: 2.0.0-beta.85 '@alchemy.run/drizzle': - specifier: 2.0.0-beta.84 - version: 2.0.0-beta.84 + specifier: 2.0.0-beta.85 + version: 2.0.0-beta.85 '@alchemy.run/planetscale': - specifier: 2.0.0-beta.84 - version: 2.0.0-beta.84 + specifier: 2.0.0-beta.85 + version: 2.0.0-beta.85 + '@alchemy.run/test': + specifier: 2.0.0-beta.85 + version: 2.0.0-beta.85 '@distilled.cloud/cloudflare': - specifier: 1.0.0-rc.17 - version: 1.0.0-rc.17 + specifier: 1.0.0-rc.18 + version: 1.0.0-rc.18 '@effect/graphql-generator': - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 '@effect/openapi-generator': - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 '@effect/sql-sqlite-do': - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 '@noble/curves': specifier: 1.9.1 version: 1.9.1 @@ -270,7 +273,7 @@ overrides: micromark-extension-llm-math: 3.1.0 micromark: 4.0.2 mdast-util-to-hast: 13.2.1 - alchemy: 2.0.0-beta.84 + alchemy: 2.0.0-beta.85 expo-audio: 58.0.9 expo-network: 58.0.3 '@clerk/expo-native-components': 0.4.0 @@ -320,11 +323,12 @@ overrides: '@clerk/expo': 4.14.0 '@clerk/react': 6.18.0 '@clerk/shared': 4.41.0 - '@effect/atom-react': 4.0.5 - '@effect/platform-node': 4.0.5 - '@effect/platform-node-shared': 4.0.5 - '@effect/sql-pg': 4.0.5 - '@effect/vitest': 4.0.5 + '@effect/atom-react': 4.1.2 + '@effect/platform-bun': 4.1.2 + '@effect/platform-node': 4.1.2 + '@effect/platform-node-shared': 4.1.2 + '@effect/sql-pg': 4.1.2 + '@effect/vitest': 4.1.2 '@effect/vitest>vitest': '-' '@expo/dom-webview': 58.0.2 '@expo/metro-config': 58.0.14 @@ -339,9 +343,9 @@ overrides: '@tailwindcss/oxide': 4.3.3 '@tailwindcss/vite': 4.3.3 '@types/node': 24.12.4 - effect: 4.0.5 - '@opencode/protocol>effect': 4.0.5 - '@opencode/schema>effect': 4.0.5 + effect: 4.1.2 + '@opencode/protocol>effect': 4.1.2 + '@opencode/schema>effect': 4.1.2 node-abi: 4.33.0 electron-webauthn>@electron-webauthn/macos: 1.3.1 '@electron-webauthn/macos>objc-js': 1.5.0 @@ -359,8 +363,9 @@ packageExtensionsChecksum: sha256-EowbsmlBFaoO0Ypufw901RnvdyjKG5dcdk2FRbc7Dr0= pnpmfileChecksum: sha256-STsbxG7+RxOFI611PrDEj1TonJgqKKllY9hYCFMLYiU= patchedDependencies: + '@alchemy.run/test@2.0.0-beta.85': 29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e '@clerk/expo-native-components@0.4.0': 7a808b92da8460750b85818552e13be3fb73d111691c7db76965970351e124e4 - '@effect/vitest@4.0.5': 2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02 + '@effect/vitest@4.1.2': 299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7 '@expo/fingerprint@0.21.5': 8fba80d6bbce46c72de427fe8c7907462d262a0bfe50c6b2b7ee9c4e0c61e5e1 '@expo/metro-config@58.0.14': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 '@expo/ui@58.0.20': 64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce @@ -371,7 +376,6 @@ patchedDependencies: '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': 910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6 '@react-navigation/native-stack@7.21.0': 990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42 - alchemy@2.0.0-beta.84: 824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042 app-builder-lib@26.16.1: 0fbe9ae51783f5768ebe197d12d1fdb7d477a08c92e5db2f6356bcce62613c53 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 expo-audio@58.0.9: 530af071088a809fe1a97ce2b2269c78335fbb5dcd3893d6937ff5c304c85a0a @@ -428,8 +432,8 @@ importers: specifier: 0.13.0 version: 0.13.0 '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -452,8 +456,8 @@ importers: specifier: 0.10.2 version: 0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4) effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 electron: specifier: 44.10.0 version: 44.10.0(supports-color@8.1.1) @@ -480,8 +484,8 @@ importers: version: 7.4.2 devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -535,8 +539,8 @@ importers: specifier: 0.4.0 version: 0.4.0(patch_hash=7a808b92da8460750b85818552e13be3fb73d111691c7db76965970351e124e4)(@tsc-rs/typescript@0.8.0)(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1) '@effect/atom-react': - specifier: 4.0.5 - version: 4.0.5(effect@4.0.5)(react@19.3.0)(scheduler@0.28.0) + specifier: 4.1.2 + version: 4.1.2(effect@4.1.2)(react@19.3.0)(scheduler@0.28.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -610,8 +614,8 @@ importers: specifier: 8.0.3 version: 8.0.3 effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 expo: specifier: 58.0.14 version: 58.0.14(277a83826bb26ba15256633fefeb0bc2) @@ -794,11 +798,11 @@ importers: specifier: ^7.29.7 version: 7.29.7(supports-color@8.1.1) '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -830,20 +834,20 @@ importers: apps/server: dependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.276 - version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5) + specifier: ^0.3.284 + version: 0.3.284(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5) '@colbymchenry/codegraph': specifier: 1.6.2 version: 1.6.2 '@cursor/sdk': - specifier: 1.0.38 - version: 1.0.38(zod@4.6.5) + specifier: 1.2.0 + version: 1.2.0(zod@4.6.5) '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/platform-node-shared': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.11.0 version: 0.11.0(patch_hash=cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c) @@ -871,6 +875,9 @@ importers: '@t3tools/provider-grok': specifier: workspace:* version: link:../../packages/provider-grok + '@t3tools/provider-kiro': + specifier: workspace:* + version: link:../../packages/provider-kiro '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -914,8 +921,8 @@ importers: specifier: 8.0.3 version: 8.0.3 effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 ffi-rs: specifier: 1.3.2 version: 1.3.2 @@ -958,10 +965,10 @@ importers: devDependencies: '@effect/graphql-generator': specifier: 'catalog:' - version: 4.0.5(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.5) + version: 4.1.2(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.1.2) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -1059,8 +1066,8 @@ importers: specifier: ^3.2.2 version: 3.2.2(react@19.3.0) '@effect/atom-react': - specifier: 4.0.5 - version: 4.0.5(effect@4.0.5)(react@19.3.0)(scheduler@0.28.0) + specifier: 4.1.2 + version: 4.1.2(effect@4.1.2)(react@19.3.0)(scheduler@0.28.0) '@fontsource-variable/inter': specifier: ^5.3.0 version: 5.3.0 @@ -1155,8 +1162,8 @@ importers: specifier: ^3.4.16 version: 3.4.16 effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 entities: specifier: 8.1.0 version: 8.1.0 @@ -1258,11 +1265,11 @@ importers: version: 5.0.14(@types/react@19.3.0)(react@19.3.0)(use-sync-external-store@1.6.0(react@19.3.0)) devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@tailwindcss/vite': specifier: 4.3.3 version: 4.3.3(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)) @@ -1327,8 +1334,8 @@ importers: evals: dependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -1339,12 +1346,12 @@ importers: specifier: workspace:* version: link:../packages/shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1359,28 +1366,28 @@ importers: dependencies: '@alchemy.run/axiom': specifier: 'catalog:' - version: 2.0.0-beta.84(74118100341fd69edb1510aa7b7f1bd2) + version: 2.0.0-beta.85(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2) '@alchemy.run/cloudflare': specifier: 'catalog:' - version: 2.0.0-beta.84(24ce6902b7189b66a257867375b8f56d) + version: 2.0.0-beta.85(3c6bb1d0d4de1cb5d4932cf1224d79e6) '@alchemy.run/drizzle': specifier: 'catalog:' - version: 2.0.0-beta.84(621723465c0c1236eb5d87515baf6818) + version: 2.0.0-beta.85(0f2f0b2f6e85ad5bbc9df78a7c15a609) '@alchemy.run/planetscale': specifier: 'catalog:' - version: 2.0.0-beta.84(1b64d144d22bc50b8239f5331dd49513) + version: 2.0.0-beta.85(@effect/sql-pg@4.1.2(effect@4.1.2))(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)(pg@8.23.0) '@clerk/backend': specifier: 3.25.0 version: 3.25.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@distilled.cloud/cloudflare': specifier: 'catalog:' - version: 1.0.0-rc.17(effect@4.0.5) + version: 1.0.0-rc.18(effect@4.1.2) '@effect/sql-pg': - specifier: 4.0.5 - version: 4.0.5(effect@4.0.5) + specifier: 4.1.2 + version: 4.1.2(effect@4.1.2) '@effect/sql-sqlite-do': specifier: 'catalog:' - version: 4.0.5(effect@4.0.5) + version: 4.1.2(effect@4.1.2) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -1397,24 +1404,27 @@ importers: specifier: workspace:* version: link:../../packages/shared alchemy: - specifier: 2.0.0-beta.84 - version: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + specifier: 2.0.0-beta.85 + version: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.6(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5) + version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.1.1(effect@4.1.2))(@effect/sql-pg@4.1.2(effect@4.1.2))(@effect/sql-sqlite-do@4.1.2(effect@4.1.2))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.1.2)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5) effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: + '@alchemy.run/test': + specifier: 'catalog:' + version: 2.0.0-beta.85(patch_hash=29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e)(071253407c4f3cb63d401833ee1dda26) '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@electric-sql/pglite': specifier: 0.3.15 version: 0.3.15 @@ -1438,16 +1448,16 @@ importers: dependencies: '@alchemy.run/axiom': specifier: 'catalog:' - version: 2.0.0-beta.84(74118100341fd69edb1510aa7b7f1bd2) + version: 2.0.0-beta.85(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2) '@alchemy.run/cloudflare': specifier: 'catalog:' - version: 2.0.0-beta.84(24ce6902b7189b66a257867375b8f56d) + version: 2.0.0-beta.85(3c6bb1d0d4de1cb5d4932cf1224d79e6) alchemy: - specifier: 2.0.0-beta.84 - version: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + specifier: 2.0.0-beta.85 + version: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 jose: specifier: 'catalog:' version: 6.2.2 @@ -1456,11 +1466,11 @@ importers: specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1477,15 +1487,15 @@ importers: specifier: ^1.63.0 version: 1.89.0 effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vite-plus: specifier: 1.4.0 version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -1516,6 +1526,9 @@ importers: '@t3tools/provider-grok': specifier: workspace:* version: link:../provider-grok + '@t3tools/provider-kiro': + specifier: workspace:* + version: link:../provider-kiro '@t3tools/provider-muse': specifier: workspace:* version: link:../provider-muse @@ -1535,8 +1548,8 @@ importers: specifier: workspace:* version: link:../source-control-definitions effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 jszip: specifier: 3.10.1 version: 3.10.1 @@ -1545,8 +1558,8 @@ importers: version: 0.180.0 devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/three': specifier: 0.180.0 version: 0.180.0 @@ -1557,12 +1570,12 @@ importers: packages/contracts: dependencies: effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vite-plus: specifier: 1.4.0 version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -1570,18 +1583,18 @@ importers: packages/effect-acp: dependencies: effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.5(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.5)(encoding@0.1.13) + version: 4.1.2(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.1.2)(encoding@0.1.13) '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1592,18 +1605,18 @@ importers: packages/effect-codex-app-server: dependencies: effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.5(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.5)(encoding@0.1.13) + version: 4.1.2(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.1.2)(encoding@0.1.13) '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1626,15 +1639,15 @@ importers: specifier: 8.0.3 version: 8.0.3 effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1663,15 +1676,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1694,15 +1707,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1713,8 +1726,8 @@ importers: packages/provider-cursor: dependencies: '@cursor/sdk': - specifier: 1.0.38 - version: 1.0.38(zod@4.6.5) + specifier: 1.2.0 + version: 1.2.0(zod@4.6.5) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -1728,18 +1741,18 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 yaml: specifier: ^2.9.0 version: 2.9.0 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1765,15 +1778,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1799,15 +1812,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1821,6 +1834,37 @@ importers: specifier: 1.4.0 version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-kiro: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-acp': + specifier: workspace:* + version: link:../provider-acp + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.1.2 + version: 4.1.2 + devDependencies: + '@effect/vitest': + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + effect-acp: + specifier: workspace:* + version: link:../effect-acp + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-muse: dependencies: '@muse-code/sdk': @@ -1836,15 +1880,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1862,7 +1906,7 @@ importers: version: 1.15.13 '@opencode/client': specifier: 2.0.28 - version: 2.0.28(effect@4.0.5) + version: 2.0.28(effect@4.1.2) '@opencode/protocol': specifier: 2.0.28 version: 2.0.28 @@ -1879,15 +1923,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1910,15 +1954,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1932,8 +1976,8 @@ importers: packages/provider-testing: dependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -1944,8 +1988,8 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@types/node': specifier: 24.12.4 @@ -1966,15 +2010,15 @@ importers: specifier: workspace:* version: link:../contracts effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -2006,15 +2050,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -2037,8 +2081,8 @@ importers: specifier: ^1.4.4 version: 1.4.4 effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 ffi-rs: specifier: 1.3.2 version: 1.3.2 @@ -2089,11 +2133,11 @@ importers: version: 2.9.0 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/mdast': specifier: ^4.0.4 version: 4.0.4 @@ -2122,15 +2166,15 @@ importers: specifier: 8.0.3 version: 8.0.3 effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2156,15 +2200,15 @@ importers: specifier: workspace:* version: link:../source-control-core effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2184,12 +2228,12 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -2236,15 +2280,15 @@ importers: specifier: workspace:* version: link:../source-control-core effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2270,12 +2314,12 @@ importers: specifier: workspace:* version: link:../source-control-core effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2301,18 +2345,18 @@ importers: specifier: workspace:* version: link:../source-control-core effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/graphql-generator': specifier: 'catalog:' - version: 4.0.5(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.5) + version: 4.1.2(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.1.2) '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2335,12 +2379,12 @@ importers: specifier: workspace:* version: link:../source-control-core effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2354,8 +2398,8 @@ importers: packages/source-control-testing: dependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -2366,8 +2410,8 @@ importers: specifier: workspace:* version: link:../source-control-core effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@types/node': specifier: 24.12.4 @@ -2382,15 +2426,15 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -2404,12 +2448,12 @@ importers: specifier: workspace:* version: link:../shared effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -2420,8 +2464,8 @@ importers: scripts: dependencies: '@effect/platform-node': - specifier: 4.0.5 - version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@electron/asar': specifier: ^4.3.0 version: 4.3.0 @@ -2444,8 +2488,8 @@ importers: specifier: 0.21.32 version: 0.21.32 effect: - specifier: 4.0.5 - version: 4.0.5 + specifier: 4.1.2 + version: 4.1.2 playwright-core: specifier: 1.60.0 version: 1.60.0 @@ -2460,8 +2504,8 @@ importers: version: 0.35.4(@types/node@24.12.4) devDependencies: '@effect/vitest': - specifier: 4.0.5 - version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/plist': specifier: 'catalog:' version: 3.0.5 @@ -2505,25 +2549,25 @@ packages: resolution: {integrity: sha512-IpefF5ssZVOZCD4Wu7zxt6PwZoGT2HLVjxpJ10Bjx6EAmRfLYJPjyrfxb0RYDlwvXnDb9nkQ7Do/oJVDuRFzew==} engines: {node: '>=18'} - '@alchemy.run/acme@2.0.0-beta.84': - resolution: {integrity: sha512-ma7HaEN5K7hsNlSkK7CEGn+08hKi4xP1puEAoSO7UACXc/j/2gZKljuztADyCOKOdp8nIMJ/zH12Qs7J11E8cQ==} + '@alchemy.run/acme@2.0.0-beta.85': + resolution: {integrity: sha512-hOV18VHG6ivrbPnk35hoqlCQPc6bR8b9soGmHlSkbnPIgVZUg7jpliF9AztSSJbJK4GHcBFc9hWetwCRHzGYAg==} peerDependencies: - alchemy: 2.0.0-beta.84 - effect: 4.0.5 + alchemy: 2.0.0-beta.85 + effect: 4.1.2 - '@alchemy.run/axiom@2.0.0-beta.84': - resolution: {integrity: sha512-7kZpp2Trdw7pp/27EWspAcXxcNQfxyFLwY0P/WjnDHg/WqNK0AHFNBGgQoY6jI/vA8YO5DxdzfzI53Tj6hCRZA==} + '@alchemy.run/axiom@2.0.0-beta.85': + resolution: {integrity: sha512-dpBBDxZaRlRIiszRYTUB9M3e3yN6hvkyYTpc0E66tJfcESY6rwp2O2fl38Mun5iLEyGJb2Gvv4HqPiURYWzrHw==} peerDependencies: - alchemy: 2.0.0-beta.84 - effect: 4.0.5 + alchemy: 2.0.0-beta.85 + effect: 4.1.2 - '@alchemy.run/cloudflare-runtime@2.0.0-beta.84': - resolution: {integrity: sha512-wmlfbJHBEpyKpKosIKi6yte0T6WKofed0rDHR1zk7tZdqwUKNNSRcbR9ejfJ4mty7lkQH0fLKn4vcmfnlX9KDQ==} + '@alchemy.run/cloudflare-runtime@2.0.0-beta.85': + resolution: {integrity: sha512-OyGH/j5R9QaUdMjvm3y6pIUpMa7T9SvVYvlVd+fWo/eltOf+k/KQxC12QHC2fe22GVs3apL28l9f4VUH8N9aEA==} peerDependencies: - '@distilled.cloud/cloudflare': 1.0.0-rc.17 - '@effect/platform-bun': ^4.0.5 - '@effect/platform-node': 4.0.5 - effect: 4.0.5 + '@distilled.cloud/cloudflare': 1.0.0-rc.18 + '@effect/platform-bun': 4.1.2 + '@effect/platform-node': 4.1.2 + effect: 4.1.2 rolldown: 1.2.13 vite: ^7.0.0 || ^8.0.0 peerDependenciesMeta: @@ -2536,16 +2580,16 @@ packages: vite: optional: true - '@alchemy.run/cloudflare@2.0.0-beta.84': - resolution: {integrity: sha512-8AEp0664arL1BLPIxQJ/lgIUPRvsk0SxC/6fW6S3Gt2SOauUF2YpUsgRr94m4pTX453gacMXwJ/kw8lgOv+TMw==} + '@alchemy.run/cloudflare@2.0.0-beta.85': + resolution: {integrity: sha512-gS/LYJIpEiRthx+Eqr/+O9TseXB7PGuWWkOc96oHfqwDHu+8zUzaY1pwsc0qQ1eAlgd/xy8YWnM6JThc6eXOUQ==} peerDependencies: - '@alchemy.run/domain': 2.0.0-beta.84 - '@alchemy.run/frontend-frameworks': 2.0.0-beta.84 - '@effect/platform-bun': ^4.0.5 - '@effect/platform-node': 4.0.5 - alchemy: 2.0.0-beta.84 - effect: 4.0.5 - vite: ^8.0.7 + '@alchemy.run/domain': 2.0.0-beta.85 + '@alchemy.run/frontend-frameworks': 2.0.0-beta.85 + '@effect/platform-bun': 4.1.2 + '@effect/platform-node': 4.1.2 + alchemy: 2.0.0-beta.85 + effect: 4.1.2 + vite: ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@alchemy.run/domain': optional: true @@ -2558,27 +2602,27 @@ packages: vite: optional: true - '@alchemy.run/docker@2.0.0-beta.84': - resolution: {integrity: sha512-VaUNlldyPcCrrGDCfFUJD+pvNt3Rpbly+0lzZ4S8AfkpxMD4rZy7GdXcfPzeA26tcDzfsDVax7BGlArzupEc0g==} + '@alchemy.run/docker@2.0.0-beta.85': + resolution: {integrity: sha512-AhVdcCTu/paBdDUtGc2U4WEa8+gczkkf7oH7XEeIVjlQ+xX3hmYWlspdfejoJ9pb2JT4myfvp5EeXi/Jcp+W2g==} peerDependencies: - '@effect/platform-bun': ^4.0.5 - alchemy: 2.0.0-beta.84 - effect: 4.0.5 + '@effect/platform-bun': 4.1.2 + alchemy: 2.0.0-beta.85 + effect: 4.1.2 peerDependenciesMeta: '@effect/platform-bun': optional: true - '@alchemy.run/drizzle@2.0.0-beta.84': - resolution: {integrity: sha512-C+3k4J9wE64+z4NmDB+CBIjc/xiT0Dw63Dgo5v98yh1QKCdXeo3ypBgCSQTdrvimXJEYjwF+HDfAqP84M8/8Hw==} + '@alchemy.run/drizzle@2.0.0-beta.85': + resolution: {integrity: sha512-65goZGSiDgTMBH1XXjhgP/wKd5mfhaGe9RjQHiGCwWVtwExbfc+qhYd2n7AzwkgXWWcgW7A43pv4nvtzcPsNWw==} peerDependencies: - '@alchemy.run/cloudflare': 2.0.0-beta.84 - '@effect/sql-libsql': ^4.0.5 - '@effect/sql-mysql2': ^4.0.5 - '@effect/sql-pg': 4.0.5 - alchemy: 2.0.0-beta.84 - drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc - effect: 4.0.5 + '@alchemy.run/cloudflare': 2.0.0-beta.85 + '@effect/sql-libsql': ^4.1.0 + '@effect/sql-mysql2': ^4.1.0 + '@effect/sql-pg': 4.1.2 + alchemy: 2.0.0-beta.85 + drizzle-kit: 1.0.0-rc.5 + drizzle-orm: 1.0.0-rc.5 + effect: 4.1.2 peerDependenciesMeta: '@alchemy.run/cloudflare': optional: true @@ -2591,14 +2635,14 @@ packages: drizzle-kit: optional: true - '@alchemy.run/node-utils@2.0.0-beta.84': - resolution: {integrity: sha512-UdvCtwEeLmRNN89gVjkMIQTqzkE+xPQb26dPsQM6JzDdHUyqaTyg64U4RmfUjUul0sWQbbVIeAO475GQYbGELw==} + '@alchemy.run/node-utils@2.0.0-beta.85': + resolution: {integrity: sha512-2gxqMNMoJdx13w7wlDJw+gaXQ8e1VxQFsKldOvweqpd7JEgB5TGn3oO8r1sb/GB9+UzqXdh8tKp2MOVgFOrmbg==} - '@alchemy.run/planetscale@2.0.0-beta.84': - resolution: {integrity: sha512-j0Y4oreytRcH/fickgRPP6+i4KC8vTX/gEz58WVho+hHcajgxGAcxnrSUCoV4YgKFOigR5ONkq+OhbR5a+VPVw==} + '@alchemy.run/planetscale@2.0.0-beta.85': + resolution: {integrity: sha512-rBG26S6nGVF3fzCTnvMKu/Un/5H+YepOIJyld+1JeaAKV78z3U+bYtR+dSWR6KcW8tG8xX9pj83sisZdp0iNog==} peerDependencies: - alchemy: 2.0.0-beta.84 - effect: 4.0.5 + alchemy: 2.0.0-beta.85 + effect: 4.1.2 mysql2: ^3.24.2 pg: ^8.22.0 peerDependenciesMeta: @@ -2619,14 +2663,14 @@ packages: react-devtools-core: optional: true - '@alchemy.run/sql@2.0.0-beta.84': - resolution: {integrity: sha512-GdP9zDnm6b25p8DZfeg8HbPk66pfIuTA+IHFSC/I8iKbjUE49WoqBmdSUSJtL4ZmTvndTDCYZ7Y9PehBjNTL+w==} + '@alchemy.run/sql@2.0.0-beta.85': + resolution: {integrity: sha512-8827sU12RJRNjj+HM9XMxnw86gxzrg5c4dAon7KEs9xgV96OW2RaJlcrL9bJ4L2QZWzRTayHh5S1KKio4uUAOw==} peerDependencies: - '@effect/sql-libsql': ^4.0.5 - '@effect/sql-mysql2': ^4.0.5 - '@effect/sql-pg': 4.0.5 - alchemy: 2.0.0-beta.84 - effect: 4.0.5 + '@effect/sql-libsql': ^4.1.0 + '@effect/sql-mysql2': ^4.1.0 + '@effect/sql-pg': 4.1.2 + alchemy: 2.0.0-beta.85 + effect: 4.1.2 mysql2: ^3.24.2 pg: ^8.22.0 peerDependenciesMeta: @@ -2641,8 +2685,28 @@ packages: pg: optional: true - '@anthropic-ai/claude-agent-sdk@0.3.276': - resolution: {integrity: sha512-Dic43v4uuGLhibPAArWy3RSfq3zHdn+4Oh2AI2/18a5tlbNKkvChEpazyp8c+grUJvzxkzt0jETCa1wrADbkyw==} + '@alchemy.run/test@2.0.0-beta.85': + resolution: {integrity: sha512-/dwCV4a30g75dgasqLzOnSHKU3qGInmd87C9QZja8rhr7ooZNF8kl60W0JmPhZqotICzdt9R7bI35MsUFxlLGg==} + engines: {bun: '>=1.3'} + hasBin: true + peerDependencies: + '@effect/vitest': 4.1.2 + '@opentui/core': ^0.6.6 + alchemy: 2.0.0-beta.85 + effect: 4.1.2 + vitest: ^5.0.4 + peerDependenciesMeta: + '@effect/vitest': + optional: true + '@opentui/core': + optional: true + alchemy: + optional: true + vitest: + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.284': + resolution: {integrity: sha512-NSoJwEq6nFSf8dtaacYx37QdGgqApI3eHFUlxclMLYi8irb6ZJwEaUnPnLUCyAyg9W/tMkgZC0GXWLRCc01I0w==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -3410,32 +3474,32 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20261012.1': - resolution: {integrity: sha512-dkTL9aY3V4D40b+jBpGPCistWUPFQ/M61wlWi6i1o8MvHFFgEw7EQkcaYEv6Yd3bkPDD/3TFT4hRsMb2xVZSwQ==} + '@cloudflare/workerd-darwin-64@1.20261026.1': + resolution: {integrity: sha512-5IGoYEBQdJeHUn3mOalHQOtt2NXBKvvsqiK2cwIl9OZB6DtpmEmGDzbeFmOgsEQcGrK9f4KnLbr0SQUglnibIQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20261012.1': - resolution: {integrity: sha512-2/nVGcKXjNNI+DhxKSepTKvFng9H7aN2WXv6Zu67iqgI6JrxypNt5kTPv4ZHosxrcfyKMci/2RfN7GIrN0BitQ==} + '@cloudflare/workerd-darwin-arm64@1.20261026.1': + resolution: {integrity: sha512-ewhmUr/1S8/ik7MjPcQqq7Kx91I6AVLSlTTblbU8OkZ4CiPy92N3WBNMZ06BUkJaFS+XThbXyN/4FDF6xIFo5w==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20261012.1': - resolution: {integrity: sha512-uMe7ONHnLxStD1Ye0YFHBO36uj4cStv5P53rW+5GVWXFT8BRjqVixlH0IqCAHe1Pmcwyd1+7fVoGde89MZSCJg==} + '@cloudflare/workerd-linux-64@1.20261026.1': + resolution: {integrity: sha512-S65mog+1LKp5KT3u+Q3bWg7VQhqqfLL8aM2o2gfchbXwKOX13ZKWuCq4ZxTAxFhG7M31LfEL1iV6K+myFpjhaA==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20261012.1': - resolution: {integrity: sha512-I0VMdcXk7ygG5vYDce0ePTBFdnBXC0i9WiBY5mWiv0iTinbfea6/rHqSQ7B1UMpjgrYVs9E0m4bZ9ufsk5GCFA==} + '@cloudflare/workerd-linux-arm64@1.20261026.1': + resolution: {integrity: sha512-sM+rsagLWdSGb8GP5Qz40nmufbx8EawRD+jw+ySzdiv3owLOLlP0Z1Nzl4s5gtZ6OPCHSVsQxdWNCDfnJLbiVA==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20261012.1': - resolution: {integrity: sha512-c5hQvNx574UEoknaCFvMa/NdLrrYiALNbe6sx1okW8uOiRYWxrFuA8WpXFnBJ5XJypm0TEAHYaUTGv3rRLpY7Q==} + '@cloudflare/workerd-windows-64@1.20261026.1': + resolution: {integrity: sha512-7T6udxUWajK0NIpLkmX5FU7i7MnoFYWA3CXmywvO1SmWdabuNCqcB4Wc+88QBIbYhbmAbW+7yfIe7No2nmdoSA==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -3572,38 +3636,38 @@ packages: resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==} engines: {node: '>=20.19.0'} - '@cursor/sdk-darwin-arm64@1.0.38': - resolution: {integrity: sha512-pXxj4pjbIoDo6ZjgksSf4VTD4EJz6BGDKvFjDVVUMg0PmLCVbceOnp0pqfRO8SVpdUSE45GFrXuLnSZgLag/xw==} + '@cursor/sdk-darwin-arm64@1.2.0': + resolution: {integrity: sha512-UkZ/RdxKCpOQ85nRgzSq+g0h+gIw36/lCEvQvomhs5EkSFZW3OwbeEzkAU3UYglZ7t3RNHj3mc5C5cdDtHguvw==} cpu: [arm64] os: [darwin] hasBin: true - '@cursor/sdk-darwin-x64@1.0.38': - resolution: {integrity: sha512-rutfTMq1f9WZckPFH8R5bzNzHaZg7I/TijVUkfD3GbqIqqc5OKHzdTMqZf+AFAqRDQccGJn4GLWcnQ9wTwIVNA==} + '@cursor/sdk-darwin-x64@1.2.0': + resolution: {integrity: sha512-mJTFdX+qv9dHAm59tT7uq7ci7+BDvFoNk4gdF2zGp9SLULicY+FdFD64p1qLen1bX5Y2soZaX5mWBy7BfObjcg==} cpu: [x64] os: [darwin] hasBin: true - '@cursor/sdk-linux-arm64@1.0.38': - resolution: {integrity: sha512-CqmUnM8W8W2mKucSrdHO0kn4ems82siWuo7kvVJvXCtPkaziVNC++dro5h9hSh2oVgRbB91sWQ4lzsHVIDpkvA==} + '@cursor/sdk-linux-arm64@1.2.0': + resolution: {integrity: sha512-Rs+cd5snuhxMAHkpqRfEGPB1qAsfqSOYJh5SOHZTOWy2LkRVO62WHwd4x80C2emj+dosTV5WAcuF2kMKKcrDJg==} cpu: [arm64] os: [linux] hasBin: true - '@cursor/sdk-linux-x64@1.0.38': - resolution: {integrity: sha512-RL6OJTAF3oozMG+HNtykySQ5BOP94yJNWP65wZ2yzMLTAW9ao80++OnaWJya1lndLacHLYP+QZT4igm5TT+BWQ==} + '@cursor/sdk-linux-x64@1.2.0': + resolution: {integrity: sha512-ainsGAFtkWTBZrLABp82Hvfv7VB/FxmZHE42qMVotFTRDwOY2iduvhSig910bVxv+kw3S00Srmu/9CxklLwhbw==} cpu: [x64] os: [linux] hasBin: true - '@cursor/sdk-win32-x64@1.0.38': - resolution: {integrity: sha512-IUfuCk/S/qzEB+tfT4yWH6iSMz56Lvakn1zNjdk+oAsZ7RJWtWkZcea6eqMSRB1WLmTeQG0uetXKX330w+xI0A==} + '@cursor/sdk-win32-x64@1.2.0': + resolution: {integrity: sha512-p3rkyta6R/nXIukd3/ifJq32otkkGoUi41T2jxnHbPHMhzE2F8OuJQGBVmORuQLMr+bIGGVZmCxertgs4WgzcQ==} cpu: [x64] os: [win32] hasBin: true - '@cursor/sdk@1.0.38': - resolution: {integrity: sha512-8UwPKaprIXvlVR71yHyNNTyFwZCOzfHW7W+FKjPdDS9Ym9FDZc51Jwsu9lAhxHf7n0US3t0pybkDaNFdldrQ+A==} + '@cursor/sdk@1.2.0': + resolution: {integrity: sha512-Nqm2U0zTWrYEDCv3Rdwsfa1gYZWsg+JnY+I2F8BwWSAr3FNo0TZWwyHWoWngZgriF1w02fJ4gjJWaul9zysvgQ==} engines: {node: '>=22.13'} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -3624,40 +3688,45 @@ packages: '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} - '@distilled.cloud/acme@1.0.0-rc.17': - resolution: {integrity: sha512-s6qWikqh+kdcl0dKOXvYS+++das2f073hkQtHZ9Htt7Oi0hcAFIk7i4is0h4GBjsJX/8bCx0D6EtUVeXUuQI9g==} + '@distilled.cloud/acme@1.0.0-rc.18': + resolution: {integrity: sha512-OWO6NG9IcpDqZCzlrN5T4MIQqSbpZ5cw61Ru/dYn1vr3AIyx3Vlg+Ew8NhlunpURr4wqDUW2/7QW3fKbrmfwLg==} + peerDependencies: + effect: 4.1.2 + + '@distilled.cloud/aws4signer@1.0.0-rc.18': + resolution: {integrity: sha512-eYJi2rsblwjxpa0kj2qUQYqY9cEwD6ow0njiFL6I5ZnlOqrE8I3aLiWvScoc3s9bQUQD5nFdmSnORoPJ5GsHKg==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@distilled.cloud/aws4signer@1.0.0-rc.17': - resolution: {integrity: sha512-4wOoFuiadiKOEfDcL0VwGwg4XmZ3vmMxrNJAnTKf87k2WN2Shs7KeUp4GS1TzGGO3NrrpvVw8RFK+dOomDMj5w==} + '@distilled.cloud/aws@1.0.0-rc.18': + resolution: {integrity: sha512-VZDlOGEXvXvTdY/0sTEoTWnPCGde+fjvq1ZIW3wgiZK/X8ChbbonGArg+Sc9OO4E9tr1WBymASrZ906uLnLWFw==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@distilled.cloud/axiom@1.0.0-rc.17': - resolution: {integrity: sha512-QRjfgxQsajursE+ar/urY1d3qrjmyjjZEnxNZnlJc7G/2o1p863ClcIwlyAe6u8CfeGf6HL/tc3GVmD4yHek2g==} + '@distilled.cloud/axiom@1.0.0-rc.18': + resolution: {integrity: sha512-8z6PPKm3OlEI9ovuSgqnQBYrGU5Fg8LVrk/Xs85NBDH8kJ69ttdJ8vaSb6hILi9hHluefvYO7SJ1XTOXxH+vCw==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@distilled.cloud/cloudflare@1.0.0-rc.17': - resolution: {integrity: sha512-xT7EFAAUUd9AZetUjXsiwMkpGatdxGNuMZrhuX5cG4MicmJizn9gn5xdqNsojkj4qzv08qIVL9j1XGV20IoYuA==} + '@distilled.cloud/cloudflare@1.0.0-rc.18': + resolution: {integrity: sha512-UJB7pitkg7msm7BGTuAE8yYx4GzKdPLRtiOm3G3XHw07xLUlqEiEr4xpSf0CeziWzDygjI53FSkpC2qyVZr49g==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@distilled.cloud/core@1.0.0-rc.17': - resolution: {integrity: sha512-BHcVEtqIbyyqU3S3o8DjUtqoAJ4FLnsb4BX3nTK2+mcDARS2Puc+Qak6sn+b+W5CRKOOWyrhltbc+kaYDUwxeQ==} + '@distilled.cloud/core@1.0.0-rc.18': + resolution: {integrity: sha512-Iui60+sNl1CwUu7NKGLuFf3VC4H9xYzfm0Fc8ZQvd87qwf1sDMB3EAWZrq/QFgrwzSY16z2mJ0PNHaBUnGet8A==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@distilled.cloud/iceberg@1.0.0-rc.17': - resolution: {integrity: sha512-hO9Kqc91dq7SLBCagxlr2RDSTbp7/urqjfxNitSNaaOyKIsUr85llsADT/rS6H85ezOsGf2vSP5y1Qk0kQWLCg==} + '@distilled.cloud/iceberg@1.0.0-rc.18': + resolution: {integrity: sha512-XjUJ/vEOylhIZbAm85O9H6YqXKWGfVROT5BE9BGA/RXvo0bLK2+sUcjC6BoN77a99lImJkrXJuvbHBhNebObEA==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@distilled.cloud/planetscale@1.0.0-rc.17': - resolution: {integrity: sha512-njhj8KAcjSUShf5WK1J7GaLQb496QefHgKBQnQ5g+AJr0u9VPYcugdhuslWsd+L634GL13IHjXVnB2Fp36uy9w==} + '@distilled.cloud/planetscale@1.0.0-rc.18': + resolution: {integrity: sha512-GSpQVfwYq2QKjjQpjiMl4IUBgyS7doNX4mnIzIRzM44BMMtTwx0gMcBj7b7PVAA0O7MyKLKV134Ba1+w3u71kw==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} @@ -3690,64 +3759,64 @@ packages: '@drizzle-team/brocli@0.12.0': resolution: {integrity: sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ==} - '@effect/atom-react@4.0.5': - resolution: {integrity: sha512-tqTSiy0rWBitej7kPfvTSw29q4ipof52+txDg6bIBJx0ksHVzVXaFTsssDa+AZBeImafK0xmjm9m1XzPfE6k2A==} + '@effect/atom-react@4.1.2': + resolution: {integrity: sha512-XRAm4lks2kzNFQIz9QTGKR02CaDTR9VJNRXDjDAR8OzM15ybrK4YkB7OlFZ9U+hxXe3DXJZx7RU1/xI2Em2PGQ==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 react: '>=19.0.0 <20.0.0' scheduler: '>=0.25.0 <0.29.0' - '@effect/graphql-generator@4.0.5': - resolution: {integrity: sha512-2QtEQxP4RRANWVJVe/wQBmG6SGePDfhgqpOeyfa7JlTsbnJbf3J25lmTz/faO3fGgicTVTTMruHTY6CR16KILg==} + '@effect/graphql-generator@4.1.2': + resolution: {integrity: sha512-V6y46lsINE3mHzNTYlYY4UA5RJZkywYAorPL0Bl7U4ggGk8P8gqVcc8iGnUoHzlR97TKbNucOSYvCiFv2oidVg==} hasBin: true peerDependencies: - '@effect/platform-node': 4.0.5 - effect: 4.0.5 + '@effect/platform-node': 4.1.2 + effect: 4.1.2 - '@effect/openapi-generator@4.0.5': - resolution: {integrity: sha512-pksR5BhRKQQfDcpA8AbxCiAFLAbdnZseiRmgr1UtVi1x/IyEuS8WmXoFD1C424TEyS+n2Q6lPpR0ue/WQ743aw==} + '@effect/openapi-generator@4.1.2': + resolution: {integrity: sha512-seh1li7cp6XE734rWmsQ/lQ2RFp1YCMb1PP6pZ7CvFAeoAUsgM4J2z8iCllY8yE2VnswWWw9GQ/XKHLm/gpGzg==} hasBin: true peerDependencies: - '@effect/platform-node': 4.0.5 - effect: 4.0.5 + '@effect/platform-node': 4.1.2 + effect: 4.1.2 - '@effect/platform-node-shared@4.0.5': - resolution: {integrity: sha512-a8rCdEkfZBGe9C6bsIjxVZuRp73svIyK5IHwHdDAVsvZXjrbkxusfI+rZ8WlX31RP1mKizwI/E72GQTNfdMWHw==} - engines: {node: '>=18.0.0'} + '@effect/platform-bun@4.1.2': + resolution: {integrity: sha512-waEAa84CITqjctgw9VdhlL9PyXRHICG3KAWTFRDPUE2XL/pfMPhO7V9oFdfx22dbAhhUzV+MHs0d9hnmy/DQ7w==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@effect/platform-node@4.0.5': - resolution: {integrity: sha512-v4KD5fWvEUa4QmWFpZFbBsRhmfnTP6ETCME2r9ifyXVDPjhfpxpTZl41prjgQFMqZBHdWWCl7YC5ImbXv5ui5g==} - engines: {node: '>=18.0.0'} + '@effect/platform-node-shared@4.1.2': + resolution: {integrity: sha512-wSwl9A/G1hcVvF8nLI+YfB3EyOIDvWS6+lsTZBkmgDnPBL7m8YWZF+1HD+mdEsb52SUGfukip46Q3gRwSdk3QA==} + engines: {node: '>=22.19.0'} peerDependencies: - effect: 4.0.5 - redis: '>=5.0.0 <7.0.0' + effect: 4.1.2 - '@effect/sql-d1@4.0.6': - resolution: {integrity: sha512-W149Svo5pR+7eoKtTmMX3aFCJKQmvMdypKhMlFekKvy2UjY6h5Af8d+9YeBeOXkYtDQjxNs4l6iv7EqbF6XBbg==} + '@effect/platform-node@4.1.2': + resolution: {integrity: sha512-ye0UjxORdd5Qu3paZ1LljgGOALWHt7muP9VTpjUSCNfcesiB/Hm7wPJIvZeu4gnwpTe5BtEnO1VHngWqjJp8mA==} + engines: {node: '>=22.19.0'} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 + redis: '>=5.1.0 <7.0.0' - '@effect/sql-pg@4.0.5': - resolution: {integrity: sha512-F9tG76H+aryhYcp5pvgd+MwWrNltv3XAuLcZSBsOOX6q+PEl+IM4zkQKzx0TF9OsEMH/SQ+3gZRdYWOsDF+vJQ==} + '@effect/sql-d1@4.1.1': + resolution: {integrity: sha512-CQMOiw9bmj4ifsUA4KLwIgBJBMpPHaF3OYdXnA9oOxeEmD61+XDLkJUSA2mG7BWCIEZc7MquGxl3HuUVOeqtSA==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@effect/sql-sqlite-bun@4.0.0-rc.112': - resolution: {integrity: sha512-EqR8pWZo3VzedRvP6qo5HcfqNmFvSa+sQLWQl3oBMx2KY1FCc8NYjep79wYjNkW0qABnffrPUXCMTOncd1A+5g==} + '@effect/sql-pg@4.1.2': + resolution: {integrity: sha512-0UehR7B5W8dC+dX6fDeWStLbVbBb2M4pk4HBATEeH0cmV4Odl2lckTg0jFmLuadekTLa4OyB+bZQo1yTG1y2Mw==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@effect/sql-sqlite-do@4.0.5': - resolution: {integrity: sha512-TGlU65ZJJMa81I7EoicQXxhRsJDxDPz6A/PNOPzMYRgmCdLs6EsTTXIkKZ+8apb7pl2o0OKVcrT6i6mGlluyLg==} + '@effect/sql-sqlite-do@4.1.2': + resolution: {integrity: sha512-1qlYhIvNsvYxvfp1183FPMGnHN/9TAGtlMLsBB7OrVE5R/9bWnWyahMxGID79sJ6csW3Azeyxk3ZwRdWXLvNTQ==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 - '@effect/vitest@4.0.5': - resolution: {integrity: sha512-q64paZ/i4V2NiRhTyqCmT2KiDbFjlxc2EZrZca8GGt+Nf8844946HyJM5bPk/q+YeRkFSxIrlCs+J+LSfky/wA==} + '@effect/vitest@4.1.2': + resolution: {integrity: sha512-JJPwAHkJHxvM/woW9mMW/khjasAHcmwWdOMAgMy16rfqsxcVhFDnrRsvN9VI2+kMv8zDCCyBE2q1n5hzZlfE0w==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 '@electric-sql/pglite@0.3.15': resolution: {integrity: sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==} @@ -5220,7 +5289,7 @@ packages: '@opencode/client@2.0.28': resolution: {integrity: sha512-YV0Yxk93/K2h4Xy/sEW/4ZYGMwn7ltxG2Y/Vdy2VCIZ2z0/r9MI6x5/XawSNfCvImBxuHkQNbYtu9Rgp3ArJ4g==} peerDependencies: - effect: 4.0.5 + effect: 4.1.2 solid-js: '>=1.9.0' peerDependenciesMeta: effect: @@ -7606,16 +7675,15 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - alchemy@2.0.0-beta.84: - resolution: {integrity: sha512-GAcfT/fvolC5C4t6yKx2P3e4rYmaM3aXhtnwK2FnGDFUCdv5zIQatiVXtoNuABzFgMn6UIj9jxrCWWweXoWgmA==} + alchemy@2.0.0-beta.85: + resolution: {integrity: sha512-dRSyfVDfU3wevBT0Iam6PUjQTn6PY6Lyd2BOeg9ToE208ifrx0sdmNUag8lLeiHisxlhaJJRyOtqKcWZ7lY0Pw==} hasBin: true peerDependencies: - '@alchemy.run/frontend-frameworks': 2.0.0-beta.84 - '@effect/platform-bun': ^4.0.5 - '@effect/platform-node': 4.0.5 - '@effect/vitest': 4.0.5 - effect: 4.0.5 - vite: ^8.0.7 + '@alchemy.run/frontend-frameworks': 2.0.0-beta.85 + '@effect/platform-bun': 4.1.2 + '@effect/platform-node': 4.1.2 + effect: 4.1.2 + vite: ^7.0.0 || ^8.0.0 ws: 8.22.0 peerDependenciesMeta: '@alchemy.run/frontend-frameworks': @@ -7624,8 +7692,6 @@ packages: optional: true '@effect/platform-node': optional: true - '@effect/vitest': - optional: true vite: optional: true ws: @@ -8438,7 +8504,7 @@ packages: '@effect/sql-d1': '>=4.0.0-beta.105 || >=4.0.0' '@effect/sql-libsql': '>=4.0.0-beta.105 || >=4.0.0' '@effect/sql-mysql2': '>=4.0.0-beta.105 || >=4.0.0' - '@effect/sql-pg': 4.0.5 + '@effect/sql-pg': 4.1.2 '@effect/sql-pglite': '>=4.0.0-beta.105 || >=4.0.0' '@effect/sql-sqlite-bun': '>=4.0.0-beta.105 || >=4.0.0' '@effect/sql-sqlite-do': '>=4.0.0-beta.105 || >=4.0.0' @@ -8469,7 +8535,7 @@ packages: arktype: '>=2.0.0' better-sqlite3: '>=9.3.0' bun-types: '*' - effect: 4.0.5 + effect: 4.1.2 expo-sqlite: '>=14.0.0' minipg: '>=0.2.0' mssql: ^11.0.1 @@ -8596,8 +8662,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@4.0.5: - resolution: {integrity: sha512-BY4/28NlpOd99yzmV741xbVGmp+1U7tks4oB7F/Png64bTa5tm5eCA6W0RiXsnuLDDoJpjfVSo4Dnq4BZb/89Q==} + effect@4.1.2: + resolution: {integrity: sha512-d3Oah1WyKxFLFh19yWheS5IXIVaITzxZ71JkE1rkUozq/6EcJSU5nPk5bF2QrSygZ3SQoiNQB8TsxFriyMeMaQ==} ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} @@ -12339,8 +12405,8 @@ packages: engines: {node: '>=20.11'} hasBin: true - workerd@1.20261012.1: - resolution: {integrity: sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA==} + workerd@1.20261026.1: + resolution: {integrity: sha512-zMkGDn4DHj7AEDrD0FT3VuaVf9QKWbE3Xg6j8oGt1vE+UtDY5gubW5jcyEYXULy5ojCYwVbmEyDkiukWY7gtVg==} engines: {node: '>=16'} hasBin: true @@ -12538,35 +12604,36 @@ snapshots: dependencies: json-schema: 0.4.0 - '@alchemy.run/acme@2.0.0-beta.84(74118100341fd69edb1510aa7b7f1bd2)': + '@alchemy.run/acme@2.0.0-beta.85(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)': dependencies: - '@distilled.cloud/acme': 1.0.0-rc.17(effect@4.0.5) - alchemy: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - effect: 4.0.5 + '@distilled.cloud/acme': 1.0.0-rc.18(effect@4.1.2) + alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + effect: 4.1.2 - '@alchemy.run/axiom@2.0.0-beta.84(74118100341fd69edb1510aa7b7f1bd2)': + '@alchemy.run/axiom@2.0.0-beta.85(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)': dependencies: - '@distilled.cloud/axiom': 1.0.0-rc.17(effect@4.0.5) - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - alchemy: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - effect: 4.0.5 + '@distilled.cloud/axiom': 1.0.0-rc.18(effect@4.1.2) + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + effect: 4.1.2 - '@alchemy.run/cloudflare-runtime@2.0.0-beta.84(@distilled.cloud/cloudflare@1.0.0-rc.17(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.13)': + '@alchemy.run/cloudflare-runtime@2.0.0-beta.85(@distilled.cloud/cloudflare@1.0.0-rc.18(effect@4.1.2))(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(rolldown@1.2.13)': dependencies: - '@alchemy.run/node-utils': 2.0.0-beta.84 - '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1) - '@distilled.cloud/cloudflare': 1.0.0-rc.17(effect@4.0.5) + '@alchemy.run/node-utils': 2.0.0-beta.85 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261026.1) + '@distilled.cloud/cloudflare': 1.0.0-rc.18(effect@4.1.2) '@puppeteer/browsers': 3.2.6(yauzl@3.4.0) capnp-es: 0.0.16(@tsc-rs/typescript@0.8.0) - effect: 4.0.5 + effect: 4.1.2 magic-string: 0.30.21 mime: 4.1.1 sharp: 0.35.5(@types/node@24.12.4) unenv: 2.0.0-rc.24 - workerd: 1.20261012.1 + workerd: 1.20261026.1 yauzl: 3.4.0 optionalDependencies: - '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) + '@effect/platform-node': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) rolldown: 1.2.13 vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: @@ -12574,24 +12641,27 @@ snapshots: - proxy-agent - typescript - '@alchemy.run/cloudflare@2.0.0-beta.84(24ce6902b7189b66a257867375b8f56d)': - dependencies: - '@alchemy.run/acme': 2.0.0-beta.84(74118100341fd69edb1510aa7b7f1bd2) - '@alchemy.run/cloudflare-runtime': 2.0.0-beta.84(@distilled.cloud/cloudflare@1.0.0-rc.17(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.13) - '@alchemy.run/docker': 2.0.0-beta.84(74118100341fd69edb1510aa7b7f1bd2) - '@alchemy.run/sql': 2.0.0-beta.84(1b64d144d22bc50b8239f5331dd49513) - '@distilled.cloud/aws4signer': 1.0.0-rc.17(effect@4.0.5) - '@distilled.cloud/cloudflare': 1.0.0-rc.17(effect@4.0.5) - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - '@distilled.cloud/iceberg': 1.0.0-rc.17(effect@4.0.5) - '@effect/sql-d1': 4.0.6(effect@4.0.5) - alchemy: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - effect: 4.0.5 + '@alchemy.run/cloudflare@2.0.0-beta.85(3c6bb1d0d4de1cb5d4932cf1224d79e6)': + dependencies: + '@alchemy.run/acme': 2.0.0-beta.85(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2) + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.85(@distilled.cloud/cloudflare@1.0.0-rc.18(effect@4.1.2))(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(rolldown@1.2.13) + '@alchemy.run/docker': 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2) + '@alchemy.run/sql': 2.0.0-beta.85(@effect/sql-pg@4.1.2(effect@4.1.2))(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)(pg@8.23.0) + '@distilled.cloud/aws': 1.0.0-rc.18(effect@4.1.2) + '@distilled.cloud/aws4signer': 1.0.0-rc.18(effect@4.1.2) + '@distilled.cloud/cloudflare': 1.0.0-rc.18(effect@4.1.2) + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + '@distilled.cloud/iceberg': 1.0.0-rc.18(effect@4.1.2) + '@effect/sql-d1': 4.1.1(effect@4.1.2) + alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + effect: 4.1.2 pathe: 2.0.3 + picomatch: 4.0.7 rolldown: 1.2.13 tinyglobby: 0.2.17 optionalDependencies: - '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) + '@effect/platform-node': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - '@effect/sql-libsql' @@ -12603,40 +12673,42 @@ snapshots: - proxy-agent - typescript - '@alchemy.run/docker@2.0.0-beta.84(74118100341fd69edb1510aa7b7f1bd2)': + '@alchemy.run/docker@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)': dependencies: - alchemy: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - effect: 4.0.5 + alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + effect: 4.1.2 rolldown: 1.2.13 + optionalDependencies: + '@effect/platform-bun': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) - '@alchemy.run/drizzle@2.0.0-beta.84(621723465c0c1236eb5d87515baf6818)': + '@alchemy.run/drizzle@2.0.0-beta.85(0f2f0b2f6e85ad5bbc9df78a7c15a609)': dependencies: - '@alchemy.run/sql': 2.0.0-beta.84(1b64d144d22bc50b8239f5331dd49513) - '@effect/sql-d1': 4.0.6(effect@4.0.5) - '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) - alchemy: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.6(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5) - effect: 4.0.5 + '@alchemy.run/sql': 2.0.0-beta.85(@effect/sql-pg@4.1.2(effect@4.1.2))(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)(pg@8.23.0) + '@effect/sql-d1': 4.1.1(effect@4.1.2) + '@effect/sql-sqlite-do': 4.1.2(effect@4.1.2) + alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.1.1(effect@4.1.2))(@effect/sql-pg@4.1.2(effect@4.1.2))(@effect/sql-sqlite-do@4.1.2(effect@4.1.2))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.1.2)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5) + effect: 4.1.2 optionalDependencies: - '@alchemy.run/cloudflare': 2.0.0-beta.84(24ce6902b7189b66a257867375b8f56d) - '@effect/sql-pg': 4.0.5(effect@4.0.5) + '@alchemy.run/cloudflare': 2.0.0-beta.85(3c6bb1d0d4de1cb5d4932cf1224d79e6) + '@effect/sql-pg': 4.1.2(effect@4.1.2) drizzle-kit: 1.0.0-rc.5-ab785fc transitivePeerDependencies: - mysql2 - pg - '@alchemy.run/node-utils@2.0.0-beta.84': + '@alchemy.run/node-utils@2.0.0-beta.85': dependencies: chokidar: 5.0.0 rolldown: 1.2.13 - '@alchemy.run/planetscale@2.0.0-beta.84(1b64d144d22bc50b8239f5331dd49513)': + '@alchemy.run/planetscale@2.0.0-beta.85(@effect/sql-pg@4.1.2(effect@4.1.2))(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)(pg@8.23.0)': dependencies: - '@alchemy.run/sql': 2.0.0-beta.84(1b64d144d22bc50b8239f5331dd49513) - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - '@distilled.cloud/planetscale': 1.0.0-rc.17(effect@4.0.5) - alchemy: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - effect: 4.0.5 + '@alchemy.run/sql': 2.0.0-beta.85(@effect/sql-pg@4.1.2(effect@4.1.2))(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)(pg@8.23.0) + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + '@distilled.cloud/planetscale': 1.0.0-rc.18(effect@4.1.2) + alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + effect: 4.1.2 optionalDependencies: pg: 8.23.0 transitivePeerDependencies: @@ -12649,15 +12721,27 @@ snapshots: '@types/react': 19.3.0 react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@alchemy.run/sql@2.0.0-beta.84(1b64d144d22bc50b8239f5331dd49513)': + '@alchemy.run/sql@2.0.0-beta.85(@effect/sql-pg@4.1.2(effect@4.1.2))(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)(pg@8.23.0)': dependencies: - alchemy: 2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - effect: 4.0.5 + alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + effect: 4.1.2 optionalDependencies: - '@effect/sql-pg': 4.0.5(effect@4.0.5) + '@effect/sql-pg': 4.1.2(effect@4.1.2) pg: 8.23.0 - '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5)': + '@alchemy.run/test@2.0.0-beta.85(patch_hash=29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e)(071253407c4f3cb63d401833ee1dda26)': + dependencies: + '@effect/platform-bun': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) + effect: 4.1.2 + optionalDependencies: + '@effect/vitest': 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@anthropic-ai/claude-agent-sdk@0.3.284(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.6.5) '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.6.5) @@ -13490,25 +13574,25 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1)': + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261026.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20261012.1 + workerd: 1.20261026.1 - '@cloudflare/workerd-darwin-64@1.20261012.1': + '@cloudflare/workerd-darwin-64@1.20261026.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20261012.1': + '@cloudflare/workerd-darwin-arm64@1.20261026.1': optional: true - '@cloudflare/workerd-linux-64@1.20261012.1': + '@cloudflare/workerd-linux-64@1.20261026.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20261012.1': + '@cloudflare/workerd-linux-arm64@1.20261026.1': optional: true - '@cloudflare/workerd-windows-64@1.20261012.1': + '@cloudflare/workerd-windows-64@1.20261026.1': optional: true '@cloudflare/workers-types@4.20260604.1': {} @@ -13606,22 +13690,22 @@ snapshots: '@csstools/css-tokenizer@4.0.1': {} - '@cursor/sdk-darwin-arm64@1.0.38': + '@cursor/sdk-darwin-arm64@1.2.0': optional: true - '@cursor/sdk-darwin-x64@1.0.38': + '@cursor/sdk-darwin-x64@1.2.0': optional: true - '@cursor/sdk-linux-arm64@1.0.38': + '@cursor/sdk-linux-arm64@1.2.0': optional: true - '@cursor/sdk-linux-x64@1.0.38': + '@cursor/sdk-linux-x64@1.2.0': optional: true - '@cursor/sdk-win32-x64@1.0.38': + '@cursor/sdk-win32-x64@1.2.0': optional: true - '@cursor/sdk@1.0.38(zod@4.6.5)': + '@cursor/sdk@1.2.0(zod@4.6.5)': dependencies: '@bufbuild/protobuf': 1.10.0 '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) @@ -13629,11 +13713,11 @@ snapshots: '@statsig/js-client': 3.31.0 zod: 4.6.5 optionalDependencies: - '@cursor/sdk-darwin-arm64': 1.0.38 - '@cursor/sdk-darwin-x64': 1.0.38 - '@cursor/sdk-linux-arm64': 1.0.38 - '@cursor/sdk-linux-x64': 1.0.38 - '@cursor/sdk-win32-x64': 1.0.38 + '@cursor/sdk-darwin-arm64': 1.2.0 + '@cursor/sdk-darwin-x64': 1.2.0 + '@cursor/sdk-linux-arm64': 1.2.0 + '@cursor/sdk-linux-x64': 1.2.0 + '@cursor/sdk-win32-x64': 1.2.0 '@date-fns/tz@1.5.0': {} @@ -13646,39 +13730,45 @@ snapshots: '@dimforge/rapier3d-compat@0.12.0': {} - '@distilled.cloud/acme@1.0.0-rc.17(effect@4.0.5)': + '@distilled.cloud/acme@1.0.0-rc.18(effect@4.1.2)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - effect: 4.0.5 + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + effect: 4.1.2 - '@distilled.cloud/aws4signer@1.0.0-rc.17(effect@4.0.5)': + '@distilled.cloud/aws4signer@1.0.0-rc.18(effect@4.1.2)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - effect: 4.0.5 + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + effect: 4.1.2 - '@distilled.cloud/axiom@1.0.0-rc.17(effect@4.0.5)': + '@distilled.cloud/aws@1.0.0-rc.18(effect@4.1.2)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - effect: 4.0.5 + '@distilled.cloud/aws4signer': 1.0.0-rc.18(effect@4.1.2) + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + effect: 4.1.2 - '@distilled.cloud/cloudflare@1.0.0-rc.17(effect@4.0.5)': + '@distilled.cloud/axiom@1.0.0-rc.18(effect@4.1.2)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - effect: 4.0.5 + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + effect: 4.1.2 - '@distilled.cloud/core@1.0.0-rc.17(effect@4.0.5)': + '@distilled.cloud/cloudflare@1.0.0-rc.18(effect@4.1.2)': dependencies: - effect: 4.0.5 + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + effect: 4.1.2 - '@distilled.cloud/iceberg@1.0.0-rc.17(effect@4.0.5)': + '@distilled.cloud/core@1.0.0-rc.18(effect@4.1.2)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - effect: 4.0.5 + effect: 4.1.2 - '@distilled.cloud/planetscale@1.0.0-rc.17(effect@4.0.5)': + '@distilled.cloud/iceberg@1.0.0-rc.18(effect@4.1.2)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.17(effect@4.0.5) - effect: 4.0.5 + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + effect: 4.1.2 + + '@distilled.cloud/planetscale@1.0.0-rc.18(effect@4.1.2)': + dependencies: + '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.2) + effect: 4.1.2 '@dnd-kit/accessibility@3.1.1(react@19.3.0)': dependencies: @@ -13714,65 +13804,68 @@ snapshots: '@drizzle-team/brocli@0.12.0': {} - '@effect/atom-react@4.0.5(effect@4.0.5)(react@19.3.0)(scheduler@0.28.0)': + '@effect/atom-react@4.1.2(effect@4.1.2)(react@19.3.0)(scheduler@0.28.0)': dependencies: - effect: 4.0.5 + effect: 4.1.2 react: 19.3.0 scheduler: 0.28.0 - '@effect/graphql-generator@4.0.5(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.5)': + '@effect/graphql-generator@4.1.2(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.1.2)': dependencies: - '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) - effect: 4.0.5 + '@effect/platform-node': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) + effect: 4.1.2 - '@effect/openapi-generator@4.0.5(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.5)(encoding@0.1.13)': + '@effect/openapi-generator@4.1.2(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.1.2)(encoding@0.1.13)': dependencies: - '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) - effect: 4.0.5 + '@effect/platform-node': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) + effect: 4.1.2 swagger2openapi: 7.0.8(encoding@0.1.13) transitivePeerDependencies: - encoding - '@effect/platform-node-shared@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6)': + dependencies: + '@effect/platform-node-shared': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) + effect: 4.1.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node-shared@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.2 - effect: 4.0.5 + effect: 4.1.2 ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(utf-8-validate@6.0.6) - effect: 4.0.5 + '@effect/platform-node-shared': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) + effect: 4.1.2 redis: 6.2.1 undici: 8.11.2 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/sql-d1@4.0.6(effect@4.0.5)': + '@effect/sql-d1@4.1.1(effect@4.1.2)': dependencies: '@cloudflare/workers-types': 5.20261026.1 - effect: 4.0.5 + effect: 4.1.2 - '@effect/sql-pg@4.0.5(effect@4.0.5)': + '@effect/sql-pg@4.1.2(effect@4.1.2)': dependencies: - effect: 4.0.5 - - '@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5)': - dependencies: - effect: 4.0.5 - optional: true + effect: 4.1.2 - '@effect/sql-sqlite-do@4.0.5(effect@4.0.5)': + '@effect/sql-sqlite-do@4.1.2(effect@4.1.2)': dependencies: - effect: 4.0.5 + effect: 4.1.2 - '@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)': + '@effect/vitest@4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)': dependencies: - effect: 4.0.5 + effect: 4.1.2 vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) transitivePeerDependencies: - '@arethetypeswrong/core' @@ -15228,22 +15321,22 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@opencode/client@2.0.28(effect@4.0.5)': + '@opencode/client@2.0.28(effect@4.1.2)': dependencies: '@opencode/protocol': 2.0.28 '@opencode/schema': 2.0.28 optionalDependencies: - effect: 4.0.5 + effect: 4.1.2 '@opencode/protocol@2.0.28': dependencies: '@opencode/schema': 2.0.28 - effect: 4.0.5 + effect: 4.1.2 '@opencode/schema@2.0.28': dependencies: '@standard-schema/spec': 1.1.0 - effect: 4.0.5 + effect: 4.1.2 '@oslojs/asn1@1.0.0': dependencies: @@ -17235,12 +17328,12 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.84(patch_hash=824006659fa28ca31e490ce1fc3611f77436005ce9fa0f638be46564133cf042)(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)): dependencies: - '@alchemy.run/node-utils': 2.0.0-beta.84 + '@alchemy.run/node-utils': 2.0.0-beta.85 '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.3.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)) capnweb: 0.12.0 - effect: 4.0.5 + effect: 4.1.2 fflate: 0.8.3 pathe: 2.0.3 picomatch: 4.0.7 @@ -17248,8 +17341,8 @@ snapshots: tinyglobby: 0.2.17 yaml: 2.9.0 optionalDependencies: - '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) - '@effect/vitest': 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@effect/platform-bun': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) + '@effect/platform-node': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -18206,18 +18299,17 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.6(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5): + drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.1.1(effect@4.1.2))(@effect/sql-pg@4.1.2(effect@4.1.2))(@effect/sql-sqlite-do@4.1.2(effect@4.1.2))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.1.2)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-d1': 4.0.6(effect@4.0.5) - '@effect/sql-pg': 4.0.5(effect@4.0.5) - '@effect/sql-sqlite-bun': 4.0.0-rc.112(effect@4.0.5) - '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) + '@effect/sql-d1': 4.1.1(effect@4.1.2) + '@effect/sql-pg': 4.1.2(effect@4.1.2) + '@effect/sql-sqlite-do': 4.1.2(effect@4.1.2) '@electric-sql/pglite': 0.3.15 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@types/sql.js': 1.4.11 bun-types: 1.3.14 - effect: 4.0.5 + effect: 4.1.2 expo-sqlite: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) pg: 8.23.0 sql.js: 1.15.0 @@ -18239,7 +18331,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.5: {} + effect@4.1.2: {} ejs@3.1.10: dependencies: @@ -22878,13 +22970,13 @@ snapshots: why-is-node-running@3.2.1: {} - workerd@1.20261012.1: + workerd@1.20261026.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20261012.1 - '@cloudflare/workerd-darwin-arm64': 1.20261012.1 - '@cloudflare/workerd-linux-64': 1.20261012.1 - '@cloudflare/workerd-linux-arm64': 1.20261012.1 - '@cloudflare/workerd-windows-64': 1.20261012.1 + '@cloudflare/workerd-darwin-64': 1.20261026.1 + '@cloudflare/workerd-darwin-arm64': 1.20261026.1 + '@cloudflare/workerd-linux-64': 1.20261026.1 + '@cloudflare/workerd-linux-arm64': 1.20261026.1 + '@cloudflare/workerd-windows-64': 1.20261026.1 wrap-ansi@7.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index be4d3c29ea763..df77b847c3418 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,10 +30,11 @@ allowBuilds: workerd: false catalog: - "@alchemy.run/axiom": 2.0.0-beta.84 - "@alchemy.run/cloudflare": 2.0.0-beta.84 - "@alchemy.run/drizzle": 2.0.0-beta.84 - "@alchemy.run/planetscale": 2.0.0-beta.84 + "@alchemy.run/axiom": 2.0.0-beta.85 + "@alchemy.run/cloudflare": 2.0.0-beta.85 + "@alchemy.run/drizzle": 2.0.0-beta.85 + "@alchemy.run/planetscale": 2.0.0-beta.85 + "@alchemy.run/test": 2.0.0-beta.85 "@clerk/backend": 3.25.0 "@clerk/clerk-js": 6.40.0 "@clerk/electron": 0.1.3 @@ -43,15 +44,16 @@ catalog: "@clerk/shared": 4.41.0 # Must match the version alchemy depends on: the relay overrides its # Cloudflare retry policy through this package's Retry service. - "@distilled.cloud/cloudflare": 1.0.0-rc.17 - "@effect/atom-react": 4.0.5 - "@effect/graphql-generator": 4.0.5 - "@effect/openapi-generator": 4.0.5 - "@effect/platform-node": 4.0.5 - "@effect/platform-node-shared": 4.0.5 - "@effect/sql-pg": 4.0.5 - "@effect/sql-sqlite-do": 4.0.5 - "@effect/vitest": 4.0.5 + "@distilled.cloud/cloudflare": 1.0.0-rc.18 + "@effect/atom-react": 4.1.2 + "@effect/graphql-generator": 4.1.2 + "@effect/openapi-generator": 4.1.2 + "@effect/platform-bun": 4.1.2 + "@effect/platform-node": 4.1.2 + "@effect/platform-node-shared": 4.1.2 + "@effect/sql-pg": 4.1.2 + "@effect/sql-sqlite-do": 4.1.2 + "@effect/vitest": 4.1.2 "@electron-webauthn/macos": 1.3.1 "@legendapp/list": 3.3.5 "@noble/curves": 1.9.1 @@ -66,8 +68,8 @@ catalog: "@types/react": ~19.3.0 "@types/react-dom": ~19.3.0 "@types/ws": 8.18.2 - alchemy: 2.0.0-beta.84 - effect: 4.0.5 + alchemy: 2.0.0-beta.85 + effect: 4.1.2 electron-webauthn: 1.3.1 jose: 6.2.2 lightningcss: 1.33.0 @@ -98,23 +100,24 @@ catalog: unified: 11.0.5 minimumReleaseAgeExclude: - - alchemy@2.0.0-beta.84 - - "@alchemy.run/acme@2.0.0-beta.84" - - "@alchemy.run/axiom@2.0.0-beta.84" - - "@alchemy.run/cloudflare@2.0.0-beta.84" - - "@alchemy.run/cloudflare-runtime@2.0.0-beta.84" - - "@alchemy.run/docker@2.0.0-beta.84" - - "@alchemy.run/drizzle@2.0.0-beta.84" - - "@alchemy.run/node-utils@2.0.0-beta.84" - - "@alchemy.run/planetscale@2.0.0-beta.84" - - "@alchemy.run/sql@2.0.0-beta.84" - - "@distilled.cloud/acme@1.0.0-rc.17" - - "@distilled.cloud/aws4signer@1.0.0-rc.17" - - "@distilled.cloud/axiom@1.0.0-rc.17" - - "@distilled.cloud/cloudflare@1.0.0-rc.17" - - "@distilled.cloud/core@1.0.0-rc.17" - - "@distilled.cloud/iceberg@1.0.0-rc.17" - - "@distilled.cloud/planetscale@1.0.0-rc.17" + - alchemy@2.0.0-beta.85 + - "@alchemy.run/acme@2.0.0-beta.85" + - "@alchemy.run/axiom@2.0.0-beta.85" + - "@alchemy.run/cloudflare@2.0.0-beta.85" + - "@alchemy.run/cloudflare-runtime@2.0.0-beta.85" + - "@alchemy.run/docker@2.0.0-beta.85" + - "@alchemy.run/drizzle@2.0.0-beta.85" + - "@alchemy.run/node-utils@2.0.0-beta.85" + - "@alchemy.run/planetscale@2.0.0-beta.85" + - "@alchemy.run/sql@2.0.0-beta.85" + - "@alchemy.run/test@2.0.0-beta.85" + - "@distilled.cloud/acme@1.0.0-rc.18" + - "@distilled.cloud/aws4signer@1.0.0-rc.18" + - "@distilled.cloud/axiom@1.0.0-rc.18" + - "@distilled.cloud/cloudflare@1.0.0-rc.18" + - "@distilled.cloud/core@1.0.0-rc.18" + - "@distilled.cloud/iceberg@1.0.0-rc.18" + - "@distilled.cloud/planetscale@1.0.0-rc.18" - "@tsc-rs/*" - expo-glass-effect@58.0.4 - expo-blur@58.0.4 @@ -126,15 +129,16 @@ minimumReleaseAgeExclude: - "@clerk/expo@4.14.0" - "@clerk/react@6.18.0" - "@clerk/shared@4.41.0" - - "@effect/atom-react@4.0.5" - - "@effect/graphql-generator@4.0.5" - - "@effect/openapi-generator@4.0.5" - - "@effect/platform-node-shared@4.0.5" - - "@effect/platform-node@4.0.5" - - "@effect/sql-pg@4.0.5" - - "@effect/sql-sqlite-do@4.0.5" - - "@effect/vitest@4.0.5" - - effect@4.0.5 + - "@effect/atom-react@4.1.2" + - "@effect/graphql-generator@4.1.2" + - "@effect/openapi-generator@4.1.2" + - "@effect/platform-node-shared@4.1.2" + - "@effect/platform-bun@4.1.2" + - "@effect/platform-node@4.1.2" + - "@effect/sql-pg@4.1.2" + - "@effect/sql-sqlite-do@4.1.2" + - "@effect/vitest@4.1.2" + - effect@4.1.2 - "@legendapp/list@3.3.5" - "@expo/cli@58.1.1 || 58.1.9 || 58.1.11" - "@expo/env@2.5.1" @@ -171,7 +175,6 @@ minimumReleaseAgeExclude: - expo-sqlite@58.0.13 - expo-symbols@58.0.4 - electron@44.10.0 - - "@anthropic-ai/claude-agent-sdk@0.3.276" - "@shadcn/lint@0.1.5" - cn@0.3.2 - uniwind@1.13.0 @@ -227,6 +230,13 @@ minimumReleaseAgeExclude: - "@pierre/highlights@0.2.0" - "@pierre/theming@1.0.2" - agent-device@0.21.32 + - "@cursor/sdk-darwin-arm64@1.2.0" + - "@cursor/sdk-darwin-x64@1.2.0" + - "@cursor/sdk-linux-arm64@1.2.0" + - "@cursor/sdk-linux-x64@1.2.0" + - "@cursor/sdk-win32-x64@1.2.0" + - "@cursor/sdk@1.2.0" + - "@distilled.cloud/aws@1.0.0-rc.18" overrides: # Pin every patched package, including transitive dependencies, so lockfile-free @@ -297,6 +307,7 @@ overrides: "@clerk/react": "catalog:" "@clerk/shared": "catalog:" "@effect/atom-react": "catalog:" + "@effect/platform-bun": "catalog:" "@effect/platform-node": "catalog:" "@effect/platform-node-shared": "catalog:" "@effect/sql-pg": "catalog:" @@ -361,12 +372,12 @@ patchedDependencies: micromark-extension-gfm-table@2.1.1: patches/micromark-extension-gfm-table@2.1.1.patch micromark@4.0.2: patches/micromark@4.0.2.patch mdast-util-to-hast@13.2.1: patches/mdast-util-to-hast@13.2.1.patch - alchemy@2.0.0-beta.84: patches/alchemy@2.0.0-beta.84.patch expo-audio@58.0.9: patches/expo-audio@58.0.9.patch expo-video@58.0.11: patches/expo-video@58.0.11.patch expo-network@58.0.3: patches/expo-network@58.0.3.patch "@clerk/expo-native-components@0.4.0": patches/@clerk__expo-native-components@0.4.0.patch - "@effect/vitest@4.0.5": patches/@effect__vitest@4.0.5.patch + "@alchemy.run/test@2.0.0-beta.85": patches/@alchemy.run__test@2.0.0-beta.85.patch + "@effect/vitest@4.1.2": patches/@effect__vitest@4.1.2.patch "@expo/metro-config@58.0.14": patches/@expo__metro-config@58.0.14.patch expo-sharing@58.0.19: patches/expo-sharing@58.0.19.patch expo-widgets@58.0.20: patches/expo-widgets@58.0.20.patch diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index fcb905dab3904..9016dea3a939c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2924,6 +2924,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( buildConfig.toolsets = { appimage: "1.0.3" }; const path = yield* Path.Path; const repoRoot = yield* RepoRoot; + buildConfig.afterPack = path.join(repoRoot, "scripts/linux-after-pack.ts"); buildConfig.linux = { target: target === "AppImage" ? [target, "deb"] : [target], executableName: "t3code", diff --git a/scripts/check-changed.test.ts b/scripts/check-changed.test.ts index 53d3a9b045f02..fb548ae4f5ea1 100644 --- a/scripts/check-changed.test.ts +++ b/scripts/check-changed.test.ts @@ -436,7 +436,7 @@ it("checks changed source paths through the Windows launcher", () => { expect(result.status, result.stdout + result.stderr).toBe(0); const calls = project.calls(); expect(calls.find(({ args }) => args[0] === "lint")).toEqual({ - args: ["lint", project.source], + args: ["lint", "--no-error-on-unmatched-pattern", project.source], cwd: project.root, }); expect(calls.some(({ args }) => args[0] === "fmt" && args.includes(project.source))).toBe(true); @@ -446,6 +446,21 @@ it("checks changed source paths through the Windows launcher", () => { expect(result.stdout).toContain("fixture stderr: lint"); }); +it("lints an Astro component and runs knip when only it changed", () => { + using project = launcherFixture(); + const page = "packages/example/Page.astro"; + project.write(page, "---\nconst title = 1;\n---\n

    {title}

    \n"); + const result = project.run(); + expect(result.status, result.stdout + result.stderr).toBe(0); + const calls = project.calls(); + expect(calls.find(({ args }) => args[0] === "lint")?.args).toEqual([ + "lint", + "--no-error-on-unmatched-pattern", + page, + ]); + expect(calls.some(({ args }) => args[0] === "exec" && args[1] === "knip")).toBe(true); +}); + it.each(["install", "lint"])("reports a failing %s step with its output", (step) => { using project = launcherFixture(); project.write(project.source, "export const value = 2;\n"); diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index e748af116df34..0bf963a9edbf0 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -180,7 +180,8 @@ export const comparisonBase = Effect.fn("checkChanged.comparisonBase")(function* return closest; }); -const CODE = /\.[cm]?[jt]sx?$/; +// CI's lint and knip also read Astro components' frontmatter. +const CODE = /\.(?:[cm]?[jt]sx?|astro)$/; const TEST = /\.test\.[cm]?[jt]sx?$/; // CI runs the .github/scripts tests with `node --test`; vitest passes a failing node:test file. const NODE_TEST = /^\.github[/\\]scripts[/\\]/; @@ -518,7 +519,9 @@ const checkChanged = Effect.fn("checkChanged")(function* (input: { ...(present.length > 0 ? [command("format", ["fmt", "--check", "--no-error-on-unmatched-pattern", ...present])] : []), - ...(code.length > 0 ? [command("lint", ["lint", ...code])] : []), + ...(code.length > 0 + ? [command("lint", ["lint", "--no-error-on-unmatched-pattern", ...code])] + : []), ...(runKnip ? knipPasses.map((args, index) => command(`knip ${index + 1}/${knipPasses.length}`, args)) : []), diff --git a/scripts/install.sh b/scripts/install.sh index 4a5810ce74520..7871428cef8ed 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -20,6 +20,14 @@ set -eu repo="ScriptedAlchemy/t3code" base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" t3_home="${T3CODE_HOME:-$HOME/.t3}" +case "$t3_home" in + /*) ;; + *) + # Preserve cwd's trailing newlines; trim only pwd's newline and the sentinel. + t3_cwd="$(pwd -P && printf .)" + t3_home="${t3_cwd%??}/$t3_home" + ;; +esac bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" fail() { diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 4dba7c7a4d6c9..5279d34b48223 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -12,6 +12,54 @@ const nightlies = Array.from({ length: 100 }, (_, index) => ({ tag_name: `v1.2.4-nightly.20261020.${200 - index}`, })); +describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer home", () => { + it("installs a relative home from a directory ending in a newline", async () => { + const temporaryRoot = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-home-")), + ); + const root = NodePath.join(temporaryRoot, "cwd\n"); + const homeName = "-custom home\\home\n"; + const home = NodePath.join(root, homeName); + const version = "1.2.3"; + const target = NodePath.join(home, "runtime/versions", version); + const launcher = NodePath.join(root, "bin/t3"); + try { + await NodeFSP.mkdir(target, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), `${version}\n`); + await NodeFSP.writeFile(NodePath.join(target, "t3"), "#!/bin/sh\necho 't3 v1.2.3'\n", { + mode: 0o755, + }); + const { status, stdout, stderr } = NodeChildProcess.spawnSync( + "sh", + [NodePath.resolve(import.meta.dirname, "install.sh")], + { + cwd: root, + env: { + ...process.env, + HOME: root, + T3CODE_HOME: homeName, + T3CODE_VERSION: version, + T3CODE_INSTALL_BIN_DIR: NodePath.dirname(launcher), + T3CODE_RELEASE_BASE_URL: "http://127.0.0.1:1", + }, + encoding: "utf8", + }, + ); + expect(status, stdout + stderr).toBe(0); + expect(stderr).toContain(`Installed T3 Code ${version}`); + expect(await NodeFSP.readlink(launcher)).toBe(NodePath.join(target, "t3")); + expect( + NodeChildProcess.execFileSync(launcher, ["--version"], { + cwd: NodeOS.tmpdir(), + encoding: "utf8", + }).trim(), + ).toBe("t3 v1.2.3"); + } finally { + await NodeFSP.rm(temporaryRoot, { recursive: true, force: true }); + } + }); +}); + // regression: #22490 describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer channel lookup", () => { it.each([ diff --git a/scripts/lib/server-compile-cache.test.ts b/scripts/lib/server-compile-cache.test.ts index bb9294c54219c..9853d4aed85fb 100644 --- a/scripts/lib/server-compile-cache.test.ts +++ b/scripts/lib/server-compile-cache.test.ts @@ -25,6 +25,67 @@ it.each(["x64", "arm64"])("skips prepared bytecode generation on Linux %s", asyn expect(result.stdout).toContain("Skipping unsupported target linux"); }); +it("prepares macOS x64 cache on an ARM64 build host", async () => { + const { spawnSync } = await import("node:child_process"); + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-rosetta-cache-")), + ); + try { + const resources = NodePath.join(root, "Fixture.app", "Contents", "Resources"); + const archive = NodePath.join(resources, "app.asar"); + const dist = NodePath.join(archive, "apps/server/dist"); + const helper = NodePath.join(archive, "apps/desktop/dist-electron/serverCompileCache.cjs"); + NodeFS.mkdirSync(dist, { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(helper), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(dist, "binCli-fixture.mjs"), "export const answer = 42;"); + NodeFS.writeFileSync( + helper, + `module.exports = require(${JSON.stringify(NodeURL.fileURLToPath(new URL("./server-compile-cache.ts", import.meta.url)))});`, + ); + const hook = NodeURL.fileURLToPath( + new URL("../prepare-server-compile-cache.cjs", import.meta.url), + ); + // The fixture uses Node for generation; the hook still sees an ARM64 macOS build host. + const result = spawnSync( + process.execPath, + [ + "-e", + ` + const childProcess = require("node:child_process"); + const spawn = childProcess.spawnSync; + childProcess.spawnSync = (executable, args, options) => { + require("node:assert/strict").equal(executable, ${JSON.stringify(NodePath.join(root, "Fixture.app", "Contents", "MacOS", "Fixture"))}); + return spawn(process.execPath, args, options); + }; + Object.defineProperty(process, "platform", { value: "darwin" }); + Object.defineProperty(process, "arch", { value: "arm64" }); + require(${JSON.stringify(hook)})({ + appOutDir: ${JSON.stringify(root)}, electronPlatformName: "darwin", arch: 1, + packager: { appInfo: { productFilename: "Fixture" } } + }).catch(error => { console.error(error); process.exitCode = 1; }); + `, + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + const manifest = JSON.parse( + NodeFS.readFileSync(NodePath.join(resources, "server-compile-cache/manifest.json"), "utf8"), + ); + expect(manifest.entries).toEqual([ + { + module: "app.asar/apps/server/dist/binCli-fixture.mjs", + file: expect.stringMatching(/^[a-f0-9]{8}$/), + }, + ]); + expect( + NodeFS.statSync(NodePath.join(resources, "server-compile-cache", manifest.entries[0].file)) + .size, + ).toBeGreaterThan(0); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + it("seeds relocated cache entries without allowing writes into shipped resources", () => { const root = NodeFS.realpathSync( NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-prepared-test-")), diff --git a/scripts/linux-after-pack.test.ts b/scripts/linux-after-pack.test.ts new file mode 100644 index 0000000000000..7171ac4b1f884 --- /dev/null +++ b/scripts/linux-after-pack.test.ts @@ -0,0 +1,43 @@ +// @effect-diagnostics nodeBuiltinImport:off - Applies real file modes in a temporary app folder. +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { removeSharedWriteAccess } from "./linux-after-pack.ts"; + +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("removeSharedWriteAccess", () => { + it("removes group and other write access from the packed app only", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-after-pack-")); + try { + const appOutDir = NodePath.join(root, "app"); + const modes = { + "app/t3code": [0o777, 0o755], + "app/resources.pak": [0o666, 0o644], + "app/chrome-sandbox": [0o4777, 0o4755], + "app/resources/app.asar": [0o644, 0o644], + "outside/file": [0o666, 0o666], + } as const; + await NodeFSP.mkdir(NodePath.join(appOutDir, "resources"), { recursive: true }); + await NodeFSP.mkdir(NodePath.join(root, "outside")); + for (const [file, [before]] of Object.entries(modes)) { + await NodeFSP.writeFile(NodePath.join(root, file), ""); + await NodeFSP.chmod(NodePath.join(root, file), before); + } + await NodeFSP.chmod(NodePath.join(appOutDir, "resources"), 0o777); + await NodeFSP.symlink("../outside", NodePath.join(appOutDir, "linked-dir")); + + await removeSharedWriteAccess(appOutDir); + + for (const [file, [, after]] of Object.entries(modes)) { + const { mode } = await NodeFSP.stat(NodePath.join(root, file)); + expect([file, mode & 0o7777]).toEqual([file, after]); + } + const { mode: resourcesMode } = await NodeFSP.stat(NodePath.join(appOutDir, "resources")); + expect(resourcesMode & 0o7777).toBe(0o755); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/linux-after-pack.ts b/scripts/linux-after-pack.ts new file mode 100644 index 0000000000000..37ccab08ede3e --- /dev/null +++ b/scripts/linux-after-pack.ts @@ -0,0 +1,29 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; + +const prepareServerCompileCache: (context: AfterPackContext) => Promise = + NodeModule.createRequire(import.meta.url)("./prepare-server-compile-cache.cjs"); + +interface AfterPackContext { + readonly appOutDir: string; +} + +// Electron's Linux zips can ship world-writable files (44.10.0 has 0777 binaries), and +// electron-builder copies zip modes verbatim into the .deb and AppImage. +export default async function afterPack(context: AfterPackContext): Promise { + await prepareServerCompileCache(context); + await removeSharedWriteAccess(context.appOutDir); +} + +// Walks by hand: recursive readdir follows directory symlinks out of the app folder. +export async function removeSharedWriteAccess(path: string): Promise { + const stats = await NodeFSP.lstat(path); + if (stats.isSymbolicLink()) return; + if (stats.mode & 0o022) await NodeFSP.chmod(path, stats.mode & 0o7755); + if (!stats.isDirectory()) return; + for (const entry of await NodeFSP.readdir(path)) { + await removeSharedWriteAccess(NodePath.join(path, entry)); + } +} diff --git a/scripts/mobile-behavior/device.ts b/scripts/mobile-behavior/device.ts index 916e3adb21245..e29cb0734463a 100644 --- a/scripts/mobile-behavior/device.ts +++ b/scripts/mobile-behavior/device.ts @@ -7,7 +7,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import * as NodeWS from "@effect/platform-node/NodeWS"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; @@ -183,7 +183,7 @@ export function makeDevice(options: DeviceOptions) { } }, Effect.scoped, - Effect.provide(NodeSocket.layerWebSocketConstructorWS), + Effect.provide(NodeWS.layerWebSocketConstructor), Effect.timeoutOrElse({ duration: "20 seconds", orElse: () => Effect.fail(new DeviceError({ message: "The inspector did not answer." })), diff --git a/scripts/mobile-behavior/environment.ts b/scripts/mobile-behavior/environment.ts index aa526a1d943e0..000e8d2f030bb 100644 --- a/scripts/mobile-behavior/environment.ts +++ b/scripts/mobile-behavior/environment.ts @@ -7,7 +7,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeNet from "node:net"; import * as NodePath from "node:path"; -import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import * as NodeWS from "@effect/platform-node/NodeWS"; import { CommandId, EnvironmentId, @@ -22,7 +22,7 @@ import { WsRpcGroup, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -34,7 +34,7 @@ const REPO_ROOT = NodePath.resolve(import.meta.dirname, "../.."); export const EVAL_INSTANCE = ProviderInstanceId.make("evalbot"); export const EVAL_MODEL = "eval/scripted"; -export const uuid = Effect.flatMap(Crypto.Crypto, (crypto) => crypto.randomUUIDv4()); +export const uuid = Effect.flatMap(SecureRandom.SecureRandom, (random) => random.uuidV4()); export class EnvironmentError extends Schema.TaggedError()("EnvironmentError", { message: Schema.String, @@ -226,7 +226,7 @@ export const startEnvironment = Effect.fn("environment.start")(function* (input: ), ), Layer.provide(RpcSerialization.layerJson), - Layer.provide(NodeSocket.layerWebSocketConstructorWS), + Layer.provide(NodeWS.layerWebSocketConstructor), ), ); const client = yield* RpcClient.make(WsRpcGroup).pipe(Effect.provideContext(protocol)); diff --git a/scripts/mobile-behavior/run.ts b/scripts/mobile-behavior/run.ts index 3a75d243113c5..516a145c7f920 100644 --- a/scripts/mobile-behavior/run.ts +++ b/scripts/mobile-behavior/run.ts @@ -9,6 +9,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; @@ -293,6 +294,6 @@ const command = Command.make( ); Command.run(command, { version: "0.0.0" }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide([NodeServices.layer, NodeCrypto.layer]), NodeRuntime.runMain, ); diff --git a/scripts/mobile-behavior/scenarios.ts b/scripts/mobile-behavior/scenarios.ts index 7222086413f80..4d971d4e87f76 100644 --- a/scripts/mobile-behavior/scenarios.ts +++ b/scripts/mobile-behavior/scenarios.ts @@ -4,7 +4,7 @@ * scenario and check its metrics, so one flow can serve several issues. */ import type { ThreadId } from "@t3tools/contracts"; -import type * as Crypto from "effect/Crypto"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -157,7 +157,9 @@ const failure = (effect: Effect.Effect) => new ScenarioFailure({ message: error instanceof Error ? error.message : String(error) }), ); -type Scenario = (ctx: ScenarioContext) => Effect.Effect; +type Scenario = ( + ctx: ScenarioContext, +) => Effect.Effect; export const SCENARIOS = { /** Six upward flings in the long settled thread. */ diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index 74e914312b618..ca859c259fb38 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -1,4 +1,5 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -6,7 +7,7 @@ import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -83,9 +84,9 @@ export const ensureClient = Effect.fn("ensureClient")(function* (o }); const digest = Effect.fn("nativeClient.digest")(function* (value: string | Uint8Array) { - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const bytes = yield* crypto.digest( - "SHA-256", + Digest.sha256, typeof value === "string" ? new TextEncoder().encode(value) : value, ); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -98,7 +99,7 @@ export const hashBundle = Effect.fn("hashBundle")(function* (root: string) { const entries: string[] = []; const visit = Effect.fn("mobile-native-client.visit")(function* ( relative: string, - ): Effect.fn.Return { + ): Effect.fn.Return { const names = (yield* fs.readDirectory(path.join(root, relative))).sort((a, b) => a.localeCompare(b, "en"), ); @@ -1207,7 +1208,7 @@ if (import.meta.main) { ), }), Effect.scoped, - Effect.provide([NodeServices.layer, FetchHttpClient.layer]), + Effect.provide([NodeServices.layer, NodeCrypto.layer, FetchHttpClient.layer]), NodeRuntime.runMain, ); } diff --git a/scripts/prepare-server-compile-cache.cjs b/scripts/prepare-server-compile-cache.cjs index 1c4b886d115ac..e2918c852bf7c 100644 --- a/scripts/prepare-server-compile-cache.cjs +++ b/scripts/prepare-server-compile-cache.cjs @@ -12,7 +12,15 @@ module.exports = async ({ appOutDir, electronPlatformName, arch, packager }) => return; } const targetArch = ["ia32", "x64", "armv7l", "arm64", "universal"][arch]; - if (electronPlatformName !== process.platform || targetArch !== process.arch) { + const runsWithRosetta = + electronPlatformName === "darwin" && + process.platform === "darwin" && + process.arch === "arm64" && + targetArch === "x64"; + if ( + electronPlatformName !== process.platform || + (targetArch !== process.arch && !runsWithRosetta) + ) { console.log( `[server-compile-cache] Skipping unexecutable target ${electronPlatformName}/${targetArch}`, ); @@ -43,14 +51,14 @@ module.exports = async ({ appOutDir, electronPlatformName, arch, packager }) => env.T3CODE_HOME = state; let result; try { - result = spawnSync( - executable, - [ - "-e", - `require(${JSON.stringify(helper)}).generatePreparedServerCache(${JSON.stringify(resources)}).then(()=>process.exit(0),e=>{console.error(e);process.exit(1)})`, - ], - { env, encoding: "utf8", timeout: 120000 }, + // Run a script file, not -e: -e defines a global __filename of "[eval]", which + // @effect/platform-node passes to createRequire and fails on. + const runner = path.join(state, "prepare-server-compile-cache-runner.cjs"); + fs.writeFileSync( + runner, + `require(${JSON.stringify(helper)}).generatePreparedServerCache(${JSON.stringify(resources)}).then(()=>process.exit(0),e=>{console.error(e);process.exit(1)})`, ); + result = spawnSync(executable, [runner], { env, encoding: "utf8", timeout: 120000 }); } finally { fs.rmSync(state, { recursive: true, force: true }); } diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 8fbebfed0d539..650c9b545aa32 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -37,6 +37,7 @@ const workspaceFiles = [ "packages/provider-cursor/package.json", "packages/provider-fx/package.json", "packages/provider-grok/package.json", + "packages/provider-kiro/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", "packages/pstack/package.json",