diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 6fde6fff30984..7ac0eb1106b32 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -5,10 +5,10 @@ description: Test T3 Code's web and desktop UI through its built-in Browser pane # Test T3 web and desktop -Use T3's built-in Browser panel for verification. If its tools are absent or -the panel reports unavailable, explain the blocker and stop verification. -Do not install or switch to another automation system. For native mobile -testing, use [test-t3-mobile](../test-t3-mobile/SKILL.md). +Use T3's built-in Browser panel for verification. Follow the recovery steps +below before declaring it unavailable. Do not install or switch to another +automation system. For native mobile testing, use +[test-t3-mobile](../test-t3-mobile/SKILL.md). ## Start the app @@ -36,8 +36,13 @@ model, such as Haiku for Claude or Luna for Codex. ## Use the Browser panel -Call `preview_status`, then `preview_open` if the Browser panel is -closed. Navigate to the complete startup pairing URL once with +Call `preview_status`. If no tab can be automated, call `preview_open`, including +when status reports `available: false` or `tabId: null`. Status can report +unavailable simply because no tab exists yet; opening a tab needs no user action. +Stop verification and explain the blocker only if the tools are absent or +`preview_open` reports unavailable or fails. + +Navigate to the complete startup pairing URL once with `preview_navigate`, then use `preview_snapshot` and T3's interaction tools. If the token was consumed or expired, run `node apps/server/src/bin.ts pair` for a fresh one. Keep using the same tab. diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5a16a9b4c170f..3af4e6abeafe9 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -203,6 +203,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 if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable 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 3442a54d60c4a..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)), ); @@ -337,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 f7b65dabea3f6..16d2f8e241d2f 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 12d5289af67c7..029bcaac2b487 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.test.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.test.ts @@ -74,6 +74,19 @@ describe("resolveStartupRecoveryOffer", () => { ).toEqual({ _tag: "Reinstall", version: "0.1.2" }); }); + it("blames the managed policy unless a newer version can read it", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ status: "available", availableVersion: "0.1.4" }), + "0.1.2", + true, + ), + ).toEqual({ _tag: "Update", version: "0.1.4" }); + expect( + resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2", true), + ).toEqual({ _tag: "ManagedPolicy" }); + }); + it("falls back to the releases page when this version never started", () => { expect(resolveStartupRecoveryOffer(updateState({ status: "disabled" }), "0.1.3")).toEqual({ _tag: "Releases", @@ -168,6 +181,7 @@ it.effect.each([ yield* SubscriptionRef.set(state.primaryBackendStartup, { _tag: "Failing", reason: "Server failed before readiness", + managedPolicy: false, output: "Failure details", }); yield* Deferred.await(checked); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts index ae94fd5b4c135..c8228183aad91 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -32,7 +32,8 @@ export type StartupRecoveryOffer = | { readonly _tag: "Update"; readonly version: string } | { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null } | { readonly _tag: "Reinstall"; readonly version: string } - | { readonly _tag: "Releases" }; + | { readonly _tag: "Releases" } + | { readonly _tag: "ManagedPolicy" }; const STARTUP_RECOVERY_ACTIONS = ["update", "reinstall", "releases", "copy", "quit"] as const; type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number]; @@ -41,6 +42,7 @@ type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number]; export function resolveStartupRecoveryOffer( update: DesktopUpdateState, lastStartedVersion: string | null, + managedPolicy = false, ): StartupRecoveryOffer { if (update.status === "downloading" && update.availableVersion !== null) { return { @@ -55,6 +57,8 @@ export function resolveStartupRecoveryOffer( if (updateVersion !== null && updateVersion !== update.notAppliedVersion) { return { _tag: "Update", version: updateVersion }; } + // A newer release can know a key this one refuses; an older one or the releases page cannot help. + if (managedPolicy) return { _tag: "ManagedPolicy" }; if (update.enabled && (update.status === "idle" || update.status === "checking")) { return { _tag: "Checking" }; } @@ -98,6 +102,11 @@ function describeOffer(offer: StartupRecoveryOffer): { text: string; action: str text: "Get a different version from the releases page.", action: actionLink("releases", "Open releases", true), }; + case "ManagedPolicy": + return { + text: "Your organization's managed policy stopped T3 Code from starting. Send the details below to your IT administrator.", + action: "", + }; } } @@ -171,8 +180,14 @@ export const listen: Effect.Effect< const { latest, changes: updateChanges } = yield* updates.subscribe; const updateStateRef = yield* Ref.make(latest); - const offer = Ref.get(updateStateRef).pipe( - Effect.map((state) => resolveStartupRecoveryOffer(state, lastStartedVersion)), + const offer = Effect.all([Ref.get(updateStateRef), Ref.get(failureRef)]).pipe( + Effect.map(([state, failure]) => + resolveStartupRecoveryOffer( + state, + lastStartedVersion, + Option.exists(failure, ({ managedPolicy }) => managedPolicy), + ), + ), ); const render = Effect.gen(function* () { @@ -332,7 +347,8 @@ export const listen: Effect.Effect< } if (startup._tag !== "Failing") return; if (Option.isSome(yield* Ref.get(failureRef))) return; - yield* Ref.set(failureRef, Option.some({ reason: startup.reason, output: startup.output })); + const { _tag, ...failure } = startup; + yield* Ref.set(failureRef, Option.some(failure)); // The backend manager already logs each failure's reason. yield* logWarning("primary backend failing to start"); yield* show(); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index b973f8177c9d0..aabc374798f24 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("resolvePrimary produces a stable scoped bootstrap token", () => @@ -316,7 +318,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", () => @@ -376,7 +378,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", () => { @@ -665,7 +667,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", () => @@ -733,7 +735,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", () => @@ -800,7 +802,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( @@ -837,7 +839,7 @@ describe("DesktopBackendConfiguration", () => { ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -880,7 +882,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", () => @@ -913,7 +915,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", () => @@ -962,7 +964,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 () => { @@ -983,7 +985,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 9c8186711f6ec..69a70b68a4b3d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -4,7 +4,7 @@ import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serve import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; 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"; @@ -723,14 +723,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 a5c12043d9aaa..e4714673777d9 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -1,4 +1,5 @@ import { + SERVER_EXIT_CODE_MANAGED_POLICY, SERVER_EXIT_CODE_STATE_DIR_OWNED, DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, @@ -623,56 +624,63 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("reports a crash loop with the server output on the third restart", () => - Effect.scoped( - Effect.gen(function* () { - const message = "Error: Cannot find module 'zod/v4' in /Users/René"; - let exitCount = 0; - const firstExit = yield* Deferred.make(); - const reports = yield* Queue.unbounded<{ - readonly exitCount: number; - readonly failure: DesktopBackendManager.BackendStartupFailure; - }>(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut - // lands inside the emoji's surrogate pair. - stderr: Stream.make( - new TextEncoder().encode( - `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, + // A policy refusal is reported on the first exit, since retrying cannot fix it. + it.effect.each([ + { code: 1, exits: 3, managedPolicy: false }, + { code: SERVER_EXIT_CODE_MANAGED_POLICY, exits: 1, managedPolicy: true }, + ])( + "reports exit $code with the server output after $exits exits", + ({ code, exits, managedPolicy }) => + Effect.scoped( + Effect.gen(function* () { + const message = "Error: Cannot find module 'zod/v4' in /Users/René"; + let exitCount = 0; + const firstExit = yield* Deferred.make(); + const reports = yield* Queue.unbounded<{ + readonly exitCount: number; + readonly failure: DesktopBackendManager.BackendStartupFailure; + }>(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut + // lands inside the emoji's surrogate pair. + stderr: Stream.make( + new TextEncoder().encode( + `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, + ), + new Uint8Array([0xc3]), + new Uint8Array([0xa9]), ), - new Uint8Array([0xc3]), - new Uint8Array([0xa9]), - ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), + }), + ), ), ), - ), - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - persistFailure: () => { - exitCount += 1; - return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); + httpClientLayer: layerHttpClient(() => Effect.never), + backendOutputLog: { + persistFailure: () => { + exitCount += 1; + return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); + }, }, - }, - onStartupFailure: (failure) => - Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* Deferred.await(firstExit); - yield* TestClock.adjust(Duration.minutes(1)); - const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); - assert.equal(exitsWhenReported, 3); - assert.equal(failure.reason, "The server could not start (code=1)."); - assert.isTrue(failure.output.endsWith(message)); - assert.isTrue(failure.output.isWellFormed()); - }).pipe(Effect.provide(TestClock.layer())), - ), + onStartupFailure: (failure) => + Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(firstExit); + yield* TestClock.adjust(Duration.minutes(1)); + const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); + assert.equal(exitsWhenReported, exits); + assert.equal(failure.reason, `The server could not start (code=${code}).`); + assert.equal(failure.managedPolicy, managedPolicy); + assert.isTrue(failure.output.endsWith(message)); + assert.isTrue(failure.output.isWellFormed()); + }).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("waits out a slow startup and reports one that never becomes ready", () => diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index aa3b4d59ef1bf..2f5b57c4e3c95 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -17,6 +17,7 @@ import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { + SERVER_EXIT_CODE_MANAGED_POLICY, SERVER_EXIT_CODE_STATE_DIR_OWNED, DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, @@ -223,6 +224,8 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( export interface BackendStartupFailure { readonly reason: string; + /** The server refused to start under this machine's managed policy. */ + readonly managedPolicy: boolean; /** Tail of the last run's stdout and stderr. */ readonly output: string; } @@ -635,9 +638,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( // One streaming decoder per stream keeps UTF-8 characters split across chunks intact. const makeOutputDecoders = () => ({ stdout: new TextDecoder(), stderr: new TextDecoder() }); let outputDecoders = makeOutputDecoders(); - const reportStartupFailure = (reason: string) => + const reportStartupFailure = (reason: string, managedPolicy = false) => Ref.get(outputTail).pipe( - Effect.flatMap((output) => spec.onStartupFailure?.({ reason, output }) ?? Effect.void), + Effect.flatMap( + (output) => spec.onStartupFailure?.({ reason, managedPolicy, output }) ?? Effect.void, + ), ); const { logWarning: logInstanceWarning, logError: logInstanceError } = @@ -880,7 +885,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore); return; } - yield* scheduleRestart(reason); + yield* scheduleRestart(reason, exitCode); } }), ); @@ -980,6 +985,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( reason: string, + exitCode?: number, ) { const scheduled = yield* SubscriptionRef.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { @@ -1003,8 +1009,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( reason, delayMs: Duration.toMillis(delay), }); - if (attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) { - yield* reportStartupFailure(`The server could not start (${reason}).`); + // Retrying cannot fix a policy refusal, so say so at once; retries still pick up a fix. + const managedPolicy = exitCode === SERVER_EXIT_CODE_MANAGED_POLICY; + if (managedPolicy || attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) { + yield* reportStartupFailure(`The server could not start (${reason}).`, managedPolicy); } const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( 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/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/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/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index d917af72d389b..32b9ad23f9c93 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -615,6 +615,33 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + ["darwin", true, false, true], + ["darwin", false, true, false], + ["linux", false, true, true], + ["win32", true, false, false], + ] as const)( + "reloads the preview only on the platform refresh chord: %s meta=%s ctrl=%s", + ([platform, meta, control, expected]) => + withManager( + (manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_refresh"); + yield* manager.registerWebview("tab_refresh", 42); + const preventDefault = vi.fn(); + preview.listeners.get("before-input-event")!( + { preventDefault } as never, + { type: "keyDown", key: "r", meta, control, shift: false, alt: false } as never, + ); + expect(preventDefault).toHaveBeenCalledTimes(expected ? 1 : 0); + expect(preview.reload).toHaveBeenCalledTimes(expected ? 1 : 0); + }), + platform, + ), + ); + effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0087444a8412c..18eb4b2b75968 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"; @@ -464,10 +464,10 @@ export const previewWindowOpenAction = (details: { : "navigate"; }; -const isPreviewRefreshShortcut = (input: Electron.Input): boolean => +const isPreviewRefreshShortcut = (input: Electron.Input, platform: NodeJS.Platform): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && - (input.meta || input.control) && + (platform === "darwin" ? input.meta && !input.control : input.control && !input.meta) && !input.shift && !input.alt; @@ -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; @@ -1488,7 +1488,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } return; } - if (isPreviewRefreshShortcut(input)) { + if (isPreviewRefreshShortcut(input, hostPlatform)) { event.preventDefault(); runFork( attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () => @@ -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/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 8b42f400512e9..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: "40k+", + githubStars: await fetchGithubStars(), users: "500,000", } as const; diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e6d388b9a9856..b94367a64ebf4 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -367,7 +367,7 @@ const config: ExpoConfig = { "expo-audio", { microphonePermission: "Allow T3 Code to use your microphone for voice input.", - recordAudioAndroid: false, + recordAudioAndroid: true, enableBackgroundPlayback: false, enableBackgroundRecording: false, }, @@ -381,7 +381,14 @@ const config: ExpoConfig = { recordAudioAndroid: false, }, ], - ["expo-image-picker", { photosPermission: false, microphonePermission: false }], + [ + "expo-image-picker", + { + photosPermission: false, + // `false` makes this plugin add RECORD_AUDIO to blockedPermissions, breaking Android dictation. + microphonePermission: "Allow T3 Code to use your microphone for voice input.", + }, + ], [ "expo-splash-screen", { diff --git a/apps/mobile/modules/t3-voice-transcription/android/build.gradle b/apps/mobile/modules/t3-voice-transcription/android/build.gradle new file mode 100644 index 0000000000000..05995a7cc4cce --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/build.gradle @@ -0,0 +1,31 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.voicetranscription' +version = '0.0.0' + +android { + namespace 'expo.modules.t3voicetranscription' + compileSdk rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') + implementation 'androidx.media3:media3-common:1.9.1' + implementation 'com.google.mlkit:genai-speech-recognition:1.0.0-alpha2' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2' +} + +tasks.withType(Test).configureEach { + systemProperty 'robolectric.dependency.repo.url', 'https://repo.maven.apache.org/maven2' + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-voice-transcription/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000..05bc93c9a7d5c --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt new file mode 100644 index 0000000000000..95423844c1d2c --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt @@ -0,0 +1,162 @@ +package expo.modules.t3voicetranscription + +import android.content.Context +import com.google.mlkit.genai.common.DownloadStatus +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.speechrecognition.SpeechRecognition +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerOptions +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerResponse +import com.google.mlkit.genai.speechrecognition.speechRecognizerOptions +import com.google.mlkit.genai.speechrecognition.speechRecognizerRequest +import java.io.Closeable +import java.util.Locale +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +internal class VoiceTranscriptionException( + val code: String, + message: String, + cause: Throwable? = null +) : Exception(message, cause) + +private val recognitionModes = listOf( + SpeechRecognizerOptions.Mode.MODE_ADVANCED, + SpeechRecognizerOptions.Mode.MODE_BASIC +) + +internal suspend fun isGoogleVoiceTranscriptionSupported( + createClient: (Int) -> GoogleSpeechClient +): Boolean { + for (mode in recognitionModes) { + createClient(mode).use { client -> + when (checkModelSupport(client)) { + FeatureStatus.AVAILABLE, + FeatureStatus.DOWNLOADABLE, + FeatureStatus.DOWNLOADING -> return true + } + } + } + return false +} + +internal suspend fun prepareGoogleVoiceTranscription( + createClient: (Int) -> GoogleSpeechClient +): Int { + for (mode in recognitionModes) { + createClient(mode).use { client -> + if (prepareModel(client)) return mode + } + } + throw VoiceTranscriptionException( + "unavailable", + "On-device voice transcription is not available for this device language." + ) +} + +private suspend fun prepareModel(client: GoogleSpeechClient): Boolean = try { + val status = client.checkStatus() + if (status == FeatureStatus.UNAVAILABLE) { + false + } else { + if (status == FeatureStatus.DOWNLOADABLE || + status == FeatureStatus.DOWNLOADING + ) { + client.download() + } + when (client.checkStatus()) { + FeatureStatus.UNAVAILABLE -> false + FeatureStatus.AVAILABLE -> true + else -> throw VoiceTranscriptionException( + "preparation-failed", + "Voice transcription could not prepare this language." + ) + } + } +} catch (error: GenAiException) { + if (!isModelUnavailable(error)) throw error + false +} + +// Any probe failure for one mode means "try the next mode"; prepare surfaces the real error. +private suspend fun checkModelSupport(client: GoogleSpeechClient): Int = try { + client.checkStatus() +} catch (_: GenAiException) { + FeatureStatus.UNAVAILABLE +} + +private fun isModelUnavailable(error: GenAiException): Boolean = when (error.errorCode) { + GenAiException.ErrorCode.NOT_AVAILABLE, + GenAiException.ErrorCode.NOT_SUPPORTED, + GenAiException.ErrorCode.AICORE_INCOMPATIBLE, + GenAiException.ErrorCode.NEEDS_SYSTEM_UPDATE -> true + else -> false +} + +internal suspend fun collectGoogleVoiceTranscript( + responses: Flow +): String { + val text = StringBuilder() + var partial = "" + var completed = false + responses.collect { response -> + when (response) { + // Separators between final utterances are supplied by the SDK. + is SpeechRecognizerResponse.FinalTextResponse -> { + text.append(response.text) + partial = "" + } + is SpeechRecognizerResponse.PartialTextResponse -> partial = response.text + is SpeechRecognizerResponse.ErrorResponse -> throw response.e + is SpeechRecognizerResponse.CompletedResponse -> completed = true + else -> Unit + } + } + requireCompleteAudio(completed) { "Speech recognition did not complete the recording." } + // Some sessions complete with a trailing partial instead of another final event. + return text.append(partial).toString().trim() +} + +internal interface GoogleSpeechClient : Closeable { + suspend fun checkStatus(): Int + suspend fun download() +} + +internal class MlKitSpeechClient(context: Context, locale: Locale, mode: Int) : GoogleSpeechClient { + private val context = context.applicationContext + private val recognizer = SpeechRecognition.getClient( + speechRecognizerOptions { + this.locale = locale + preferredMode = mode + } + ) + + override suspend fun checkStatus() = recognizer.checkStatus() + + override suspend fun download() { + val result = recognizer.download().first { + it is DownloadStatus.DownloadCompleted || it is DownloadStatus.DownloadFailed + } + if (result is DownloadStatus.DownloadFailed) throw result.e + } + + suspend fun transcribe(uri: String): String = withRecordedPcm(context, uri) { pcm -> + PcmPipe.open().use { pipe -> + val request = speechRecognizerRequest { audioSource = pipe.source } + try { + collectGoogleVoiceTranscript( + pacedRecordedResponses(pcm, pipe) { + recognizer.startRecognition(request) + } + ) + } finally { + // Stop inference before closing the SDK-owned descriptor, including cancellation. + withContext(NonCancellable) { runCatching { recognizer.stopRecognition() } } + } + } + } + + override fun close() = recognizer.close() +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt new file mode 100644 index 0000000000000..8673ce0d790e8 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt @@ -0,0 +1,85 @@ +package expo.modules.t3voicetranscription + +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerResponse +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +internal interface PcmOutput : AutoCloseable { + suspend fun write(buffer: ByteArray, count: Int) + fun finish() + fun verifyConsumed() +} + +private suspend fun replayPcm(file: File, output: PcmOutput) { + val expected = file.length() + var sent = 0L + file.inputStream().use { input -> + val buffer = ByteArray(3200) + while (true) { + val count = input.read(buffer) + if (count == -1) break + requireCompleteAudio(count > 0 && count % 2 == 0) { + "Recorded PCM contains an incomplete audio frame." + } + // STREAMING requires real-time delivery, including the final short buffer. + delay((count * 1000L + 31999) / 32000) + output.write(buffer, count) + sent += count + } + } + requireCompleteAudio(sent == expected && file.length() == expected) { + "The recording changed while speech recognition was reading it. No transcript was added." + } +} + +internal fun pacedRecordedResponses( + file: File, + output: PcmOutput, + responses: () -> Flow +): Flow = flow { + coroutineScope { + val exhausted = AtomicBoolean() + val sender = async { + replayPcm(file, output) + exhausted.set(true) + output.finish() + } + try { + withTimeout(file.length() / 32 + 30000) { + responses().collect { emit(it) } + requireCompleteAudio(exhausted.get()) { + "Speech recognition ended before receiving the complete recording. " + + "No transcript was added. Retry dictation." + } + sender.await() + output.verifyConsumed() + } + } catch (e: TimeoutCancellationException) { + throw VoiceTranscriptionException( + "incomplete-audio", + "Speech recognition did not finish the complete recording. No transcript was added. Retry dictation.", + e + ) + } finally { + sender.cancel() + try { + output.finish() + } finally { + withContext(NonCancellable) { sender.join() } + } + } + } +} + +internal inline fun requireCompleteAudio(condition: Boolean, message: () -> String) { + if (!condition) throw VoiceTranscriptionException("incomplete-audio", message()) +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmPipe.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmPipe.kt new file mode 100644 index 0000000000000..b654ce01a8116 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmPipe.kt @@ -0,0 +1,105 @@ +package expo.modules.t3voicetranscription + +import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import com.google.mlkit.genai.common.audio.AudioSource +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.TimeoutCancellationException +import java.util.concurrent.atomic.AtomicBoolean + +internal class PcmPipe private constructor( + val source: AudioSource, + private val writer: ParcelFileDescriptor, + verificationReader: ParcelFileDescriptor +) : PcmOutput { + private val closed = AtomicBoolean() + private val reader = ParcelFileDescriptor.AutoCloseInputStream(verificationReader) + override suspend fun write(buffer: ByteArray, count: Int) { + try { + withTimeout(5000) { + var offset = 0 + while (offset < count) { + currentCoroutineContext().ensureActive() + try { + val written = Os.write(writer.fileDescriptor, buffer, offset, count - offset) + requireCompleteAudio(written > 0) { "On-device speech stopped reading the recording." } + offset += written + } catch (e: ErrnoException) { + if (e.errno != OsConstants.EAGAIN) { + throw VoiceTranscriptionException( + "incomplete-audio", + "On-device speech stopped reading the complete recording. No transcript was added. Retry dictation.", + e + ) + } + delay(10) + } + } + } + } catch (e: TimeoutCancellationException) { + throw VoiceTranscriptionException( + "incomplete-audio", + "On-device speech could not read the complete recording. No transcript was added. Retry dictation.", + e + ) + } + } + override fun finish() = writer.close() + override fun verifyConsumed() { + requireCompleteAudio(reader.available() == 0) { + "Speech recognition ended with unread recorded audio. No transcript was added. Retry dictation." + } + } + override fun close() { + if (!closed.compareAndSet(false, true)) return + try { + writer.close() + } finally { + try { + reader.close() + } finally { + source.close() + } + } + } + companion object { + fun open(): PcmPipe { + val ends = ParcelFileDescriptor.createPipe() + var sdkReader: ParcelFileDescriptor? = null + var retained = false + try { + Os.fcntlInt(ends[1].fileDescriptor, OsConstants.F_SETFL, OsConstants.O_NONBLOCK) + // The SDK closes its descriptor; verification must retain a separate read end. + sdkReader = ends[0].dup() + val pipe = PcmPipe( + AudioSource.fromPfd(sdkReader, AudioSource.Mode.STREAMING), + ends[1], + ends[0] + ) + retained = true + return pipe + } finally { + if (!retained) closeFailedPipe(sdkReader, ends) + } + } + private fun closeFailedPipe( + sdkReader: ParcelFileDescriptor?, + ends: Array + ) { + try { + sdkReader?.close() + } finally { + try { + ends[0].close() + } finally { + ends[1].close() + } + } + } + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/RecordedPcm.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/RecordedPcm.kt new file mode 100644 index 0000000000000..b719981df19bd --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/RecordedPcm.kt @@ -0,0 +1,172 @@ +package expo.modules.t3voicetranscription + +import android.content.Context +import android.media.AudioFormat +import android.media.MediaCodec +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri +import java.io.Closeable +import java.io.File +import java.io.OutputStream +import java.nio.ByteOrder +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout + +internal suspend fun withRecordedPcm( + context: Context, + uri: String, + transcribe: suspend (File) -> T +): T { + val pcm = File.createTempFile("dictation-", ".pcm", context.cacheDir) + try { + try { + withTimeout(30000) { decodeRecording(context, Uri.parse(uri), pcm) } + } catch (_: TimeoutCancellationException) { + throw VoiceTranscriptionException( + "incomplete-audio", + "Audio decoding did not finish the recording. Retry dictation." + ) + } + requireCompleteAudio(pcm.length() > 0 && pcm.length() % 2 == 0L) { + "The recording did not contain complete audio samples." + } + return transcribe(pcm) + } finally { + pcm.delete() + } +} + +private suspend fun decodeRecording(context: Context, uri: Uri, output: File) { + val extractor = MediaExtractor() + Closeable { extractor.release() }.use { + extractor.setDataSource(context, uri, null) + val track = (0 until extractor.trackCount).firstOrNull { + extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true + } ?: throw VoiceTranscriptionException("incomplete-audio", "The recording has no audio track.") + extractor.selectTrack(track) + decodeTrack(extractor, extractor.getTrackFormat(track), output) + } +} + +private suspend fun decodeTrack(extractor: MediaExtractor, format: MediaFormat, output: File) { + format.setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_16BIT) + val decoder = MediaCodec.createDecoderByType( + requireNotNull(format.getString(MediaFormat.KEY_MIME)) + ) + Closeable { decoder.release() }.use { + decoder.configure(format, null, null, 0) + decoder.start() + Closeable { decoder.stop() }.use { + output.outputStream().buffered().use { stream -> decodeBuffers(decoder, extractor, stream) } + } + } +} + +private suspend fun decodeBuffers( + decoder: MediaCodec, + extractor: MediaExtractor, + stream: OutputStream +) { + var converter: RecordingPcmConverter? = null + var decodedFormat: Triple? = null + try { + var inputEnded = false + var outputEnded = false + val info = MediaCodec.BufferInfo() + while (!outputEnded) { + currentCoroutineContext().ensureActive() + if (!inputEnded) inputEnded = queueDecoderInput(decoder, extractor) + val index = decoder.dequeueOutputBuffer(info, 10000) + when { + index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + val next = pcmFormat(decoder.outputFormat, decodedFormat) + decodedFormat = next + converter = converter ?: createConverter(next, stream) + } + index >= 0 -> { + consumeDecodedBuffer(decoder, index, info, requireNotNull(converter)) + outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 + } + } + } + requireCompleteAudio(inputEnded) { + "Audio decoding ended before reading the complete recording." + } + converter?.finish() + } finally { + converter?.close() + } +} + +private fun pcmFormat( + format: MediaFormat, + previous: Triple? +): Triple { + val encoding = if (format.containsKey(MediaFormat.KEY_PCM_ENCODING)) { + format.getInteger(MediaFormat.KEY_PCM_ENCODING) + } else { + AudioFormat.ENCODING_PCM_16BIT + } + requireCompleteAudio(encoding == AudioFormat.ENCODING_PCM_16BIT) { + "The decoder did not provide 16-bit audio." + } + val next = Triple( + format.getInteger(MediaFormat.KEY_SAMPLE_RATE), + format.getInteger(MediaFormat.KEY_CHANNEL_COUNT), + encoding + ) + requireCompleteAudio(previous == null || previous == next) { + "The recording changed audio format while decoding." + } + return next +} + +private fun createConverter( + format: Triple, + stream: OutputStream +): RecordingPcmConverter { + val bytes = ByteArray(8192) + return RecordingPcmConverter(format.first, format.second) { buffer -> + while (buffer.hasRemaining()) { + val count = minOf(buffer.remaining(), bytes.size) + buffer.get(bytes, 0, count) + stream.write(bytes, 0, count) + } + } +} + +private fun queueDecoderInput(decoder: MediaCodec, extractor: MediaExtractor): Boolean { + val index = decoder.dequeueInputBuffer(10000) + if (index < 0) return false + val buffer = requireNotNull(decoder.getInputBuffer(index)) + val count = extractor.readSampleData(buffer, 0) + return if (count < 0) { + decoder.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + true + } else { + decoder.queueInputBuffer(index, 0, count, extractor.sampleTime, 0) + extractor.advance() + false + } +} + +private fun consumeDecodedBuffer( + decoder: MediaCodec, + index: Int, + info: MediaCodec.BufferInfo, + converter: RecordingPcmConverter +) { + try { + if (info.size <= 0 || info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) return + val buffer = requireNotNull(decoder.getOutputBuffer(index)) + buffer.position(info.offset) + buffer.limit(info.offset + info.size) + buffer.order(ByteOrder.nativeOrder()) + converter.write(buffer) + } finally { + decoder.releaseOutputBuffer(index, false) + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/RecordingPcmConverter.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/RecordingPcmConverter.kt new file mode 100644 index 0000000000000..3272b2bd7d5f5 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/RecordingPcmConverter.kt @@ -0,0 +1,68 @@ +package expo.modules.t3voicetranscription + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.audio.AudioProcessingPipeline +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.common.audio.ChannelMixingAudioProcessor +import androidx.media3.common.audio.ChannelMixingMatrix +import androidx.media3.common.audio.SonicAudioProcessor +import androidx.media3.common.util.UnstableApi +import com.google.common.collect.ImmutableList +import java.nio.ByteBuffer + +@OptIn(UnstableApi::class) +internal class RecordingPcmConverter( + sampleRate: Int, + channels: Int, + private val consume: (ByteBuffer) -> Unit +) : AutoCloseable { + private val frameSize = channels * 2 + private val pipeline = AudioProcessingPipeline( + ImmutableList.of( + ChannelMixingAudioProcessor().apply { + putChannelMixingMatrix(ChannelMixingMatrix.createForConstantGain(channels, 1)) + }, + SonicAudioProcessor().apply { setOutputSampleRateHz(16000) } + ) + ) + + init { + pipeline.configure(AudioProcessor.AudioFormat(sampleRate, channels, C.ENCODING_PCM_16BIT)) + pipeline.flush() + } + + fun write(input: ByteBuffer) { + requireCompleteAudio(input.remaining() % frameSize == 0) { + "Decoded audio contains an incomplete sample." + } + if (!pipeline.isOperational) { + consume(input) + return + } + while (input.hasRemaining()) { + val before = input.remaining() + pipeline.queueInput(input) + val output = pipeline.output + val produced = output.remaining() + consume(output) + requireCompleteAudio(input.remaining() < before || produced > 0) { + "Audio conversion stopped before the recording ended." + } + } + } + + fun finish() { + if (!pipeline.isOperational) return + pipeline.queueEndOfStream() + while (!pipeline.isEnded) { + val output = pipeline.output + requireCompleteAudio(output.hasRemaining() || pipeline.isEnded) { + "Audio conversion did not finish the recording." + } + consume(output) + } + } + + override fun close() = pipeline.reset() +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt new file mode 100644 index 0000000000000..b49791bea2b9b --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt @@ -0,0 +1,112 @@ +package expo.modules.t3voicetranscription + +import android.content.res.Resources +import android.os.Build +import expo.modules.kotlin.Promise +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.util.Locale +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class T3VoiceTranscriptionModule : Module() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val lock = Any() + private var operation: Pair? = null + + override fun definition() = ModuleDefinition { + Name("T3VoiceTranscription") + + AsyncFunction("checkAvailability").Coroutine { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + false + } else { + withContext(Dispatchers.IO) { + val context = requireNotNull(appContext.reactContext) + val locale = Resources.getSystem().configuration.locales[0] + isGoogleVoiceTranscriptionSupported { MlKitSpeechClient(context, locale, it) } + } + } + } + + AsyncFunction("prepare") { id: Int, promise: Promise -> + runOperation(id, promise, "preparation-failed") { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + throw VoiceTranscriptionException( + "unavailable", + "Voice transcription requires Android 12 or later." + ) + } + val context = requireNotNull(appContext.reactContext) + val locale = Resources.getSystem().configuration.locales[0] + val mode = prepareGoogleVoiceTranscription { MlKitSpeechClient(context, locale, it) } + mapOf("locale" to locale.toLanguageTag(), "mode" to mode) + } + } + + AsyncFunction("transcribe") { + id: Int, + uri: String, + language: String, + mode: Int, + promise: Promise + -> + runOperation(id, promise, "transcription-failed") { + val context = requireNotNull(appContext.reactContext) + MlKitSpeechClient(context, Locale.forLanguageTag(language), mode).use { it.transcribe(uri) } + } + } + + AsyncFunction("cancel") { id: Int, promise: Promise -> + scope.launch { + val job = synchronized(lock) { operation?.takeIf { it.first == id }?.second } + job?.cancelAndJoin() + promise.resolve(null) + } + } + + OnDestroy { scope.cancel() } + } + + private fun runOperation( + id: Int, + promise: Promise, + errorCode: String, + block: suspend () -> Any? + ) { + var result: Result? = null + val job = scope.launch(start = CoroutineStart.LAZY) { + result = runCatching { block() } + } + synchronized(lock) { + if (operation != null) { + job.cancel() + promise.reject("busy", "Another voice transcription is already active.", null) + return + } + operation = id to job + } + job.invokeOnCompletion { cause -> + synchronized(lock) { if (operation?.first == id) operation = null } + val outcome = cause?.let { Result.failure(it) } ?: result + ?: Result.failure(CancellationException("Voice transcription was cancelled.")) + outcome.fold( + onSuccess = { promise.resolve(it) }, + onFailure = { error -> + val code = voiceTranscriptionErrorCode(error, errorCode) + promise.reject(code, error.message, error) + } + ) + } + job.start() + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/VoiceTranscriptionFailure.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/VoiceTranscriptionFailure.kt new file mode 100644 index 0000000000000..bc3dca6f4efbf --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/VoiceTranscriptionFailure.kt @@ -0,0 +1,24 @@ +package expo.modules.t3voicetranscription + +import com.google.mlkit.genai.common.GenAiException +import kotlinx.coroutines.CancellationException + +internal fun voiceTranscriptionErrorCode( + error: Throwable, + fallback: String +): String = when (error) { + is CancellationException -> "cancelled" + is VoiceTranscriptionException -> error.code + is GenAiException -> when { + error.message.orEmpty().contains("NO_SPEECH_DETECTED") -> "no-speech" + error.errorCode == GenAiException.ErrorCode.NOT_ENOUGH_DISK_SPACE -> "storage" + error.errorCode == GenAiException.ErrorCode.PER_APP_BATTERY_USE_QUOTA_EXCEEDED -> "quota" + error.errorCode == GenAiException.ErrorCode.BUSY -> "busy" + error.errorCode == GenAiException.ErrorCode.AUDIO_BUFFER_OVERFLOW -> "audio-overflow" + error.errorCode == GenAiException.ErrorCode.NEEDS_SYSTEM_UPDATE || + error.errorCode == GenAiException.ErrorCode.AICORE_INCOMPATIBLE -> "needs-update" + error.message.orEmpty().contains("DOWNLOAD_ERROR") -> "download-failed" + else -> fallback + } + else -> fallback +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriberTest.kt b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriberTest.kt new file mode 100644 index 0000000000000..aaa76dfbf07e2 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriberTest.kt @@ -0,0 +1,211 @@ +package expo.modules.t3voicetranscription + +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerOptions +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerResponse +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GoogleVoiceTranscriberTest { + @Test + fun preservesUtterancesAndTheirSpacingWithoutDuplicatingHypotheses() = runBlocking { + for ((finals, trailing, expected) in listOf( + Triple( + listOf("The beginning.", " The middle.", " Repeat this.", " Repeat this."), + emptyList(), + "The beginning. The middle. Repeat this. Repeat this." + ), + Triple(listOf("最初の文。", "次の文。"), emptyList(), "最初の文。次の文。"), + Triple( + listOf("The beginning."), + listOf(" An unfinished ending", " The final sentence."), + "The beginning. The final sentence." + ) + )) { + val responses = flow { + for (text in finals) { + emit(SpeechRecognizerResponse.PartialTextResponse("Unfinished hypothesis")) + emit(SpeechRecognizerResponse.FinalTextResponse(text, emptyList())) + } + for (text in trailing) emit(SpeechRecognizerResponse.PartialTextResponse(text)) + emit(SpeechRecognizerResponse.CompletedResponse) + } + assertEquals(expected, collectGoogleVoiceTranscript(responses)) + } + val incomplete = runCatching { + collectGoogleVoiceTranscript( + flow { + emit(SpeechRecognizerResponse.FinalTextResponse("Incomplete recording.", emptyList())) + } + ) + } + assertEquals( + "incomplete-audio", + (incomplete.exceptionOrNull() as VoiceTranscriptionException).code + ) + } + + private class Client( + var status: Int, + val onStatus: () -> Unit = {}, + val downloadedStatus: Int = FeatureStatus.AVAILABLE, + val onDownload: suspend () -> Unit = {} + ) : GoogleSpeechClient { + var closed = false + var downloads = 0 + + override suspend fun checkStatus(): Int { + onStatus() + return status + } + + override suspend fun download() { + downloads += 1 + onDownload() + status = downloadedStatus + } + + override fun close() { + closed = true + } + } + + @Test + fun checksSupportWithoutDownloadingAndClosesProbedClients() = runBlocking { + for ((advancedStatus, basicStatus, supported) in listOf( + Triple(FeatureStatus.AVAILABLE, FeatureStatus.UNAVAILABLE, true), + Triple(FeatureStatus.DOWNLOADABLE, FeatureStatus.UNAVAILABLE, true), + Triple(FeatureStatus.DOWNLOADING, FeatureStatus.UNAVAILABLE, true), + Triple(FeatureStatus.UNAVAILABLE, FeatureStatus.AVAILABLE, true), + Triple(FeatureStatus.UNAVAILABLE, FeatureStatus.DOWNLOADABLE, true), + Triple(FeatureStatus.UNAVAILABLE, FeatureStatus.DOWNLOADING, true), + Triple(FeatureStatus.UNAVAILABLE, FeatureStatus.UNAVAILABLE, false) + )) { + val clients = mutableListOf() + assertEquals( + supported, + isGoogleVoiceTranscriptionSupported { mode -> + Client( + if (mode == SpeechRecognizerOptions.Mode.MODE_ADVANCED) advancedStatus else basicStatus + ).also { clients.add(it) } + } + ) + assertTrue(clients.all { it.closed && it.downloads == 0 }) + assertEquals(if (advancedStatus == FeatureStatus.UNAVAILABLE) 2 else 1, clients.size) + } + } + + @Test + fun preparesAdvancedBeforeConsideringBasic() = runBlocking { + for (status in listOf( + FeatureStatus.AVAILABLE, + FeatureStatus.DOWNLOADABLE, + FeatureStatus.DOWNLOADING + )) { + val client = Client(status) + val mode = prepareGoogleVoiceTranscription { + assertEquals(SpeechRecognizerOptions.Mode.MODE_ADVANCED, it) + client + } + assertEquals(SpeechRecognizerOptions.Mode.MODE_ADVANCED, mode) + assertEquals(if (status == FeatureStatus.AVAILABLE) 0 else 1, client.downloads) + assertTrue(client.closed) + } + } + + @Test + fun fallsBackToBasicOnlyForUnavailableAdvancedModels() = runBlocking { + for ((duringDownload, errorCode) in listOf( + false to null, + false to GenAiException.ErrorCode.NOT_AVAILABLE, + false to GenAiException.ErrorCode.NOT_SUPPORTED, + true to GenAiException.ErrorCode.NOT_AVAILABLE, + true to GenAiException.ErrorCode.NOT_SUPPORTED, + true to null + )) { + val advanced = Client( + if (duringDownload) FeatureStatus.DOWNLOADABLE else FeatureStatus.UNAVAILABLE, + downloadedStatus = if (duringDownload && + errorCode == null + ) { + FeatureStatus.UNAVAILABLE + } else { + FeatureStatus.AVAILABLE + }, + onStatus = { + if (!duringDownload && errorCode != null) { + throw GenAiException(Exception("Unavailable"), errorCode) + } + }, + onDownload = { + if (duringDownload && errorCode != null) { + throw GenAiException(Exception("Unavailable"), errorCode) + } + } + ) + val basic = Client(FeatureStatus.DOWNLOADABLE) + val mode = prepareGoogleVoiceTranscription { + if (it == SpeechRecognizerOptions.Mode.MODE_ADVANCED) advanced else basic + } + assertEquals(SpeechRecognizerOptions.Mode.MODE_BASIC, mode) + assertTrue(advanced.closed && basic.closed) + assertEquals(1, basic.downloads) + } + val clients = mutableListOf() + val result = runCatching { + prepareGoogleVoiceTranscription { Client(FeatureStatus.UNAVAILABLE).also { clients.add(it) } } + } + assertEquals("unavailable", (result.exceptionOrNull() as VoiceTranscriptionException).code) + assertTrue(clients.all { it.closed }) + } + + @Test + fun preservesDownloadAndQuotaFailuresInsteadOfSilentlyFallingBack() = runBlocking { + for (errorCode in listOf( + GenAiException.ErrorCode.NOT_ENOUGH_DISK_SPACE, + GenAiException.ErrorCode.PER_APP_BATTERY_USE_QUOTA_EXCEEDED + )) { + val error = GenAiException(Exception("Preparation failed"), errorCode) + val client = Client(FeatureStatus.DOWNLOADABLE, onDownload = { throw error }) + val result = runCatching { + prepareGoogleVoiceTranscription { + assertEquals(SpeechRecognizerOptions.Mode.MODE_ADVANCED, it) + client + } + } + assertEquals(error, result.exceptionOrNull()) + assertEquals( + if (errorCode == GenAiException.ErrorCode.NOT_ENOUGH_DISK_SPACE) "storage" else "quota", + voiceTranscriptionErrorCode(error, "preparation-failed") + ) + assertTrue(client.closed) + } + } + + @Test + fun cancellationClosesTheDownloadingClientWithoutStartingBasic() = runBlocking { + val enteredDownload = CompletableDeferred() + val download = CompletableDeferred() + val client = Client(FeatureStatus.DOWNLOADABLE, onDownload = { + enteredDownload.complete(Unit) + download.await() + }) + val job = launch { + prepareGoogleVoiceTranscription { + assertEquals(SpeechRecognizerOptions.Mode.MODE_ADVANCED, it) + client + } + } + enteredDownload.await() + job.cancelAndJoin() + assertTrue(client.closed) + assertEquals(FeatureStatus.DOWNLOADABLE, client.status) + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/PcmReplayTest.kt b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/PcmReplayTest.kt new file mode 100644 index 0000000000000..f226cbebca7cb --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/PcmReplayTest.kt @@ -0,0 +1,126 @@ +package expo.modules.t3voicetranscription + +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerResponse +import java.io.ByteArrayOutputStream +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class PcmReplayTest { + private class Output : PcmOutput { + val bytes = ByteArrayOutputStream() + val ended = CompletableDeferred() + var unread = false + var closed = false + + override suspend fun write(buffer: ByteArray, count: Int) { + bytes.write(buffer, 0, count) + } + override fun finish() { + ended.complete(Unit) + } + override fun verifyConsumed() { + requireCompleteAudio(!unread) { "Unread audio." } + } + override fun close() { + closed = true + } + } + + @Test fun replaysTheEntireLongFileIncludingTheLastShortBuffer() = runTest { + val pcm = ByteArray(1994140) { (it % 251).toByte() } + val file = File.createTempFile("dictation-replay-", ".pcm").apply { writeBytes(pcm) } + try { + Output().use { output -> + val responses = pacedRecordedResponses(file, output) { + flow { + output.ended.await() + emit(SpeechRecognizerResponse.FinalTextResponse("Opening. ", emptyList())) + emit(SpeechRecognizerResponse.FinalTextResponse("Repeat. Repeat. 中間。", emptyList())) + emit(SpeechRecognizerResponse.PartialTextResponse(" A longer unfinished ending")) + emit(SpeechRecognizerResponse.PartialTextResponse(" End.")) + emit(SpeechRecognizerResponse.CompletedResponse) + } + } + assertEquals("Opening. Repeat. Repeat. 中間。 End.", collectGoogleVoiceTranscript(responses)) + assertArrayEquals(pcm, output.bytes.toByteArray()) + assertEquals(62317L, currentTime) + } + } finally { + file.delete() + } + } + + @Test fun refusesSuccessfulSdkCompletionBeforeAllAudioIsConsumed() = runTest { + val file = File.createTempFile("dictation-replay-", ".pcm").apply { + writeBytes(ByteArray(6400)) + } + try { + for (early in listOf(true, false)) { + Output().use { output -> + output.unread = !early + val result = runCatching { + collectGoogleVoiceTranscript( + pacedRecordedResponses(file, output) { + flow { + if (!early) output.ended.await() + emit( + SpeechRecognizerResponse.FinalTextResponse( + "Plausible but incomplete.", + emptyList() + ) + ) + emit(SpeechRecognizerResponse.CompletedResponse) + } + } + ) + } + assertEquals( + "incomplete-audio", + (result.exceptionOrNull() as VoiceTranscriptionException).code + ) + assertTrue(output.ended.isCompleted) + } + } + } finally { + file.delete() + } + } + + @Test fun cancellationSettlesTheSenderBeforeReleasingItsOutput() = runTest { + val file = File.createTempFile("dictation-replay-", ".pcm").apply { + writeBytes(ByteArray(6400)) + } + val entered = CompletableDeferred() + val output = Output() + try { + val job = launch { + output.use { + collectGoogleVoiceTranscript( + pacedRecordedResponses(file, it) { + flow { + entered.complete(Unit) + CompletableDeferred().await() + } + } + ) + } + } + entered.await() + job.cancelAndJoin() + assertTrue(output.ended.isCompleted) + assertTrue(output.closed) + } finally { + file.delete() + } + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/RecordingPcmConverterTest.kt b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/RecordingPcmConverterTest.kt new file mode 100644 index 0000000000000..694523610e880 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/RecordingPcmConverterTest.kt @@ -0,0 +1,51 @@ +package expo.modules.t3voicetranscription + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.abs +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [31]) +class RecordingPcmConverterTest { + @Test fun preservesAlreadyCompatiblePcmAndResamplesStereoThroughItsTail() { + for ((rate, channels) in listOf(16000 to 1, 44100 to 2, 48000 to 2)) { + val frames = rate * 2 + 37 + val input = ByteBuffer.allocateDirect(frames * channels * 2).order(ByteOrder.nativeOrder()) + repeat(frames) { frame -> + val level = if (frame < rate) 6000 else 18000 + repeat(channels) { input.putShort(level.toShort()) } + } + input.flip() + val original = ByteArray(input.remaining()).also { input.duplicate().get(it) } + val result = ByteArrayOutputStream() + RecordingPcmConverter(rate, channels) { buffer -> + val bytes = ByteArray(buffer.remaining()) + buffer.get(bytes) + result.write(bytes) + }.use { converter -> + while (input.hasRemaining()) { + val end = minOf(input.limit(), input.position() + channels * 2 * 317) + val block = input.slice().order(ByteOrder.nativeOrder()) + block.limit(end - input.position()) + converter.write(block) + input.position(end) + } + converter.finish() + } + val bytes = result.toByteArray() + if (rate == 16000) assertArrayEquals(original, bytes) + val samples = ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder()).asShortBuffer() + assertTrue(abs(samples.remaining() * rate.toLong() - frames * 16000L) <= rate) + assertEquals(6000, samples[8000].toInt()) + assertEquals(18000, samples[samples.limit() - 10].toInt()) + } + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/expo-module.config.json b/apps/mobile/modules/t3-voice-transcription/expo-module.config.json new file mode 100644 index 0000000000000..5867f41ffe75e --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.t3voicetranscription.T3VoiceTranscriptionModule"] + } +} 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/ui/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx index a1f6a02cc32eb..369fcf19ef55a 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx @@ -4,7 +4,6 @@ import { use, useRef, useState } from "react"; import type { StyleProp, ViewInstance, ViewStyle } from "react-native"; import { Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; -import Animated, { FadeIn } from "react-native-reanimated"; import { useHardwareBack } from "../../lib/backHandler"; import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; @@ -181,8 +180,8 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { onClose={close} /> ) : ( - - + )} 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 688b974e7658d..ce1830b8c5710 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", () => @@ -1408,9 +1450,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( @@ -1421,18 +1465,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({ @@ -1440,8 +1522,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"]); }), ); @@ -1807,6 +1892,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)), + ); + + it.effect("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")); + yield* TestClock.adjust("2 seconds"); + 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 bbd3ba5a631e0..3ed97f8a7d8f4 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -13,6 +13,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"; @@ -30,6 +31,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"; @@ -64,6 +66,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), @@ -718,6 +735,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; @@ -726,7 +744,8 @@ export const make = Effect.gen(function* () { const projectStore = yield* ProjectStore.ProjectStoreV2; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = 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`. @@ -773,48 +792,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* ( @@ -903,7 +996,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; @@ -937,7 +1029,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") { @@ -997,7 +1089,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; }), ), @@ -1404,6 +1496,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(); @@ -1411,28 +1527,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); } @@ -1563,17 +1667,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); @@ -1596,7 +1696,6 @@ export const make = Effect.gen(function* () { } if ( transcriptsRemaining === 0 || - recordsRemaining === 0 || identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || identity.size > bytesRemaining ) { @@ -1604,16 +1703,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" }); @@ -1680,4 +1773,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.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 41804842d5205..28b1c40541ddf 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -22,7 +22,7 @@ import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; -import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; +import * as ProjectionReplay from "../orchestration-v2/testkit/ProjectionReplay.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; @@ -40,7 +40,7 @@ const layerEventPersistence = EventSink.layer.pipe( ); const layerServices = Layer.mergeAll( LegacyV1ThreadImporter.layer.pipe(Layer.provideMerge(layerEventPersistence)), - ProjectionMaintenance.layer.pipe(Layer.provide(layerEventPersistence)), + ProjectionReplay.layer.pipe(Layer.provide(layerEventPersistence)), ProjectStore.layer, IdAllocator.layer, ThreadCommandExecutor.layer, @@ -310,7 +310,7 @@ it.effect( yield* Effect.gen(function* () { const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const projections = yield* ProjectionStore.ProjectionStoreV2; const service = yield* ProjectService.make; assert.deepEqual(yield* importer.reconcileShells, { diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 828ddf5d92a74..11d3fea8f064a 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* ( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index ec11ce9840448..96b574f2f8955 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"; @@ -199,7 +199,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")( @@ -430,7 +430,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 = { @@ -442,9 +442,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/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 885053c7d0f24..c4e3b812408f8 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"; @@ -77,7 +78,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"); @@ -132,7 +133,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 f4c249625f3a1..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 { @@ -121,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)), ), ), ); 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/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index e0683199ffd5f..daf1dac2654de 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"; @@ -56,8 +58,8 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( ); } let downloads = 0; - 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 = { @@ -166,7 +168,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([ { version: "0.128.9" }, @@ -189,7 +191,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* ( @@ -219,7 +221,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", () => @@ -251,7 +253,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", () => @@ -265,7 +267,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([ @@ -304,7 +306,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( @@ -324,7 +326,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", () => @@ -354,7 +356,34 @@ 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("updates an older supported managed installation to the pinned release on startup", () => + Effect.gen(function* () { + const first = yield* makeHarness(); + yield* first.installation.start; + yield* terminalState(first.installation); + const executable = yield* first.installation.resolve(); + const oldDirectory = `${first.installation.managedDirectory}/0.156.0`; + yield* first.fs.rename(executable.managedVersionDirectory!, oldDirectory); + for (const name of ["codex-package.json", ".install-complete.json"]) { + const file = `${oldDirectory}/${name}`; + yield* first.fs.writeFileString( + file, + (yield* first.fs.readFileString(file)).replaceAll("0.156.1", "0.156.0"), + ); + } + yield* first.fs.writeFileString( + `${first.installation.managedDirectory}/active.json`, + '{"version":"0.156.0"}', + ); + const restarted = yield* makeHarness({ baseDir: first.baseDir }); + expect((yield* restarted.installation.state).operationId).not.toBeNull(); + 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(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("rejects corrupted downloads without publishing a runtime", () => @@ -368,7 +397,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)( @@ -405,7 +434,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( @@ -424,7 +453,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", () => @@ -443,7 +472,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", () => @@ -462,7 +491,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", () => @@ -478,7 +507,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", () => @@ -496,7 +525,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))), ); // 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 7cd973b73fa76..8fdcfbebb328e 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -5,12 +5,13 @@ import { type ServerProviderCompatibilityAdvisory, } from "@t3tools/contracts"; import { cliArchiveTarCommand } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; 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"; @@ -58,46 +59,46 @@ export interface CodexReleaseAsset { } const RELEASES: Readonly> = { "darwin-arm64": { - version: "0.156.1", + version: "0.166.0", target: "aarch64-apple-darwin", - url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-apple-darwin.tar.gz", - sha256: "fea42f9625091f011e38f059da974d52e57ba31831648bb1c7f0b1a385fde547", - archiveBytes: 127394863, + url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-aarch64-apple-darwin.tar.gz", + sha256: "55a440cb579f970abe7c79ab181e54b0ea4c419be99f333bbd868eebac9c21f0", + archiveBytes: 165951869, }, "darwin-x64": { - version: "0.156.1", + version: "0.166.0", target: "x86_64-apple-darwin", - url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-apple-darwin.tar.gz", - sha256: "618dbcd55419fa041871f777a14b107ceb3fe2d339ef81e21e6ab5374420dc71", - archiveBytes: 138670455, + url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-x86_64-apple-darwin.tar.gz", + sha256: "1cb65690230be5982c654ba2655e3a3b594dce9c55b780e78bfdfeff829d7ad9", + archiveBytes: 180076524, }, "linux-arm64": { - version: "0.156.1", + version: "0.166.0", target: "aarch64-unknown-linux-musl", - url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-unknown-linux-musl.tar.gz", - sha256: "fdd47ed6aade0360796fd3f6f95a45096f327c15e19e8c7339f9dc5633041786", - archiveBytes: 136933361, + url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-aarch64-unknown-linux-musl.tar.gz", + sha256: "7821b77dda2e6283fb1f63f3165177a271da8833e857cf673c6ca1c9db3bfcc6", + archiveBytes: 187370368, }, "linux-x64": { - version: "0.156.1", + version: "0.166.0", target: "x86_64-unknown-linux-musl", - url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-unknown-linux-musl.tar.gz", - sha256: "8b711520beddf385467b8da4d2c93736637c6ba1e46811cf0d8606b7c490b6f6", - archiveBytes: 145976992, + url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-x86_64-unknown-linux-musl.tar.gz", + sha256: "1d44b28bc4ac9937d7d3532264baa68a507ce364956a0d4ebb856f331cdee254", + archiveBytes: 199694962, }, "win32-arm64": { - version: "0.156.1", + version: "0.166.0", target: "aarch64-pc-windows-msvc", - url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-pc-windows-msvc.tar.gz", - sha256: "85994caecdc7609c49fd585c1cdb5677fa9d0acbf789650cff23a13afc9505db", - archiveBytes: 142037952, + url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-aarch64-pc-windows-msvc.tar.gz", + sha256: "9383152a72f97bb5dd148493c8214e4fed38d805aa2c3297cb3739f7f130becf", + archiveBytes: 187024318, }, "win32-x64": { - version: "0.156.1", + version: "0.166.0", target: "x86_64-pc-windows-msvc", - url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-pc-windows-msvc.tar.gz", - sha256: "a2e017db9807e6a2269a26fea0e1d9546469cef4d472a33016bc9f3ad7d3b733", - archiveBytes: 153839991, + url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-x86_64-pc-windows-msvc.tar.gz", + sha256: "d559bb88e74d471265857b93b7f19bc250dae527673b9191b33534478093f6bc", + archiveBytes: 202589789, }, }; const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) => @@ -170,7 +171,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 spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serviceScope = yield* Effect.scope; @@ -653,16 +654,14 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function `OpenAI does not publish a Codex 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, + ...current, operationId, phase: "downloading", downloadedBytes: 0, totalBytes: asset.archiveBytes, version: asset.version, - installedVersion: current.installedVersion, - canRemove: current.canRemove, message: "Downloading Codex.", }; yield* SubscriptionRef.set(state, next); @@ -807,6 +806,9 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function source: "managed", }) satisfies ProviderInstallState, ); + // A supported older runtime still resolves, so a raised pin needs its own update. + if (asset && compareSemverVersions(installed.version, asset.version) < 0) + yield* start.pipe(Effect.ignore); }).pipe( Effect.catch(() => SubscriptionRef.update( diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 8f8408bdaee1f..754716ac53106 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"; @@ -271,7 +272,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/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 74f4daa1323af..c4486916c7d3c 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 { @@ -10,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient } from "effect/http"; @@ -160,20 +162,24 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( return yield* Effect.die("Unexpected process pipeline."); 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; @@ -240,7 +246,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 0131346e8d2ec..a8f97b0f14743 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"; @@ -75,7 +76,8 @@ export type AntigravityDriverEnv = | AntigravityInstallation.AntigravityInstallation | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | IdAllocator.IdAllocatorV2 @@ -102,7 +104,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; @@ -133,7 +136,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) => @@ -255,7 +258,7 @@ export const AntigravityDriver: ProviderDriver< auth, runtimeTempDirectory, }), - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + }).pipe(Effect.provideService(SecureRandom.SecureRandom, random)); return { ...runtime, start: () => @@ -402,7 +405,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, { 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.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index d4a16a2220465..c0a9c5698d92c 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,7 +1,7 @@ import { ClaudeSettings, ProviderDriverKind } 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"; @@ -42,13 +42,7 @@ import { type ProviderInstance, } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { - enrichProviderSnapshotWithVersionAdvisory, - makeCachedProviderMaintenanceResolution, - makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, -} from "@t3tools/provider-core/server/maintenanceResolver"; +import { enrichProviderSnapshotWithVersionAdvisory } from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -61,6 +55,7 @@ import { resolveClaudeHomePath, } 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"); @@ -72,30 +67,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 @@ -144,15 +121,14 @@ export const ClaudeDriver: ProviderDriver(); + 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/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index c047336ef5dfc..985c14df21103 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"; @@ -45,7 +46,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( @@ -653,7 +654,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 61f26c3f8cc7d..c34aa00a19646 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,5 +1,6 @@ import { CodexSettings, ProviderDriverKind } 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 Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -98,7 +99,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 20939e54db799..90f7c78b849f8 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..9be799112cc23 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; @@ -720,9 +722,39 @@ it.layer(NodeServices.layer)("readGrokAccount", (it) => { } }), ); + + it.effect("tells accounts in Legacy (no storage) to switch to Privacy Mode", () => + Effect.gen(function* () { + const { usageLimits: limits } = yield* readGrokAccount({ + HOME: "/definitely/not/a/grok-home", + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"private-token"}}', + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { + error: + "Grok Build is not supported in Legacy (no storage). Switch to Privacy Mode to use Grok Build. [WKE=privacy-mode:legacy]", + }, + { status: 403 }, + ), + ), + ), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("probeFailed"); + expect(limits.unavailable?.message).toMatch(/Privacy Mode/); + expect(limits.unavailable?.message).not.toMatch(/WKE=|Grok Build is not supported/); + }), + ); }); -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/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 8d73d697705fc..6398210599b61 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -1,6 +1,6 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; -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"; @@ -20,7 +20,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; return ProviderHost.ProviderHost.of({ paths: { @@ -68,7 +68,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 cf9a6c4f8e175..4bae35385d62b 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, @@ -170,7 +171,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/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 9dd93bd3f5856..fbf7a5320c4a4 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -9,7 +9,7 @@ import { ServerSettings, } from "@t3tools/contracts"; 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 Layer from "effect/Layer"; @@ -43,7 +43,7 @@ type ProviderInstanceRegistryHydrationEnv = | Path.Path | FileSystem.FileSystem // Secret provider reads. - | Crypto.Crypto + | SecureRandom.SecureRandom // The Claude runner counts its CLIs as running agents for port discovery. | PortScanner.PortDiscovery; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index f04db9118ecc3..93fec081ebb9c 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -4,6 +4,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert, expect } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; @@ -330,6 +331,7 @@ const awaitPersistedProvider = ( const layerTestNodeServices = Layer.mergeAll( ProviderLatestVersions.layer, NodeServices.layer, + NodeCrypto.layer, Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({ endpoint: Effect.succeed("unused-private-pipe"), }), @@ -1066,6 +1068,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, @@ -1083,8 +1086,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, }, @@ -1105,6 +1110,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( @@ -1141,7 +1147,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))), ), ), ); @@ -1201,6 +1207,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, @@ -1218,8 +1225,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, }, @@ -1243,6 +1252,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 @@ -1277,7 +1287,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))), ), ), ); @@ -1476,6 +1486,143 @@ it.layer( ); }); + it.effect("invalidates model caches only for an explicit model refresh", () => + 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 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, + }, + invalidateCaches: Ref.update(invalidations, (count) => count + 1), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const takeMaintenanceReads = Ref.getAndSet(maintenanceReads, []); + return { instance, invalidations, takeMaintenanceReads }; + }); + const first = yield* makeInstance("codex_first"); + const second = yield* makeInstance("codex_second"); + const instances = [first.instance, second.instance]; + 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]); + }).pipe( + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge(layerModelManifest), + Layer.provideMerge(layerLatestVersions), + 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", () => @@ -1548,7 +1695,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)); @@ -1644,7 +1791,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)); @@ -1765,7 +1912,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)); @@ -1787,6 +1934,133 @@ 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, + }, + 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"); @@ -1873,7 +2147,7 @@ it.layer( }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -2162,7 +2436,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)); @@ -2267,7 +2541,7 @@ it.layer( }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -2360,7 +2634,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( @@ -2465,7 +2739,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 e8a13a0617c34..76f2723cd9d4f 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -81,6 +81,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, @@ -593,7 +604,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), @@ -1026,6 +1039,34 @@ export const layer = Layer.effect( return yield* Fiber.join(claim.scan); }); + const invalidateModelCaches = Effect.fn("ProviderRegistry.invalidateModelCaches")(function* ( + input: Parameters[0], + ) { + yield* manifestService.forceRefresh; + const instances = yield* instanceRegistry.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => input.instanceId === undefined || input.instanceId === 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), refresh: (provider?: ProviderDriverKind) => @@ -1037,6 +1078,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/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 653fb1d9cbfbc..8957c12747a89 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"; @@ -91,7 +93,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", () => @@ -105,7 +107,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", () => @@ -190,7 +192,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", () => @@ -248,7 +250,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", () => @@ -288,7 +290,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", () => @@ -310,7 +312,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([ @@ -358,7 +360,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", () => @@ -384,7 +386,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", () => @@ -414,7 +416,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", () => @@ -445,7 +447,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", () => { @@ -491,7 +493,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -543,7 +545,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -587,7 +589,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -629,7 +631,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -686,7 +688,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -724,7 +726,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", () => @@ -767,7 +769,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -828,7 +830,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -874,7 +876,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -921,7 +923,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1000,7 +1002,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -1049,7 +1051,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1077,7 +1079,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1108,7 +1110,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1145,7 +1147,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1194,7 +1196,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 }))), ); }); @@ -1231,7 +1233,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.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index a2be5ccaa3f6a..52fbd6c6070fb 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -7,7 +7,7 @@ import { type RuntimeMode, } 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 Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -111,7 +111,7 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( ): Effect.fn.Return< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Crypto.Crypto | Scope.Scope + SecureRandom.SecureRandom | Scope.Scope > { const context = yield* Layer.build( AcpSessionRuntime.layer({ diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 4a386b100f055..2b2ae029e5b0e 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"; @@ -178,9 +178,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/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.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/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 6f3e49e4d4c46..77a44c7fa29a5 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -211,6 +211,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/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 85b76eb3fc63d..66e7d64d70a64 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 a4008acc18efb..f75ecd141e71a 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -14,6 +14,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 004e883784499..0f068df5ef829 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 66e5a3def7c07..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"; @@ -165,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; @@ -366,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; @@ -479,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", { @@ -517,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; @@ -538,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); @@ -557,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; @@ -640,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 83a0adc5f11dd..50a99b953d327 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -1,7 +1,12 @@ -import { secretReferencesInSettings, withSettingsSecrets } from "@t3tools/contracts"; +import { + isSecretReference, + secretReferencesInSettings, + type SettingsSecretValue, + 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"; +import * as Schema from "effect/Schema"; /** * Replaces each reference in the slots `schema` declares as `SettingsSecretValue` with the secret @@ -18,3 +23,29 @@ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(funct pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })), ); }); + +export class SettingsSecretUnresolvedError extends Schema.TaggedError()( + "SettingsSecretUnresolvedError", + { provider: Schema.String }, +) { + override get message(): string { + return `Could not read the credential from the secret provider "${this.provider}". Unlock or fix it in Settings, then try again.`; + } +} + +/** + * The secret a credential setting holds or names. A named secret that cannot be read, or reads + * empty, fails instead of passing for a missing token, so callers that would fall back to another + * credential cannot silently switch accounts. + */ +export const requireSettingsSecret = Effect.fn("SettingsSecrets.require")(function* ( + resolver: SecretResolver.SecretResolver["Service"], + value: SettingsSecretValue, +) { + if (!isSecretReference(value)) return value; + const [secret] = yield* resolver.read([value]); + if (secret === undefined || secret.trim() === "") { + return yield* new SettingsSecretUnresolvedError({ provider: value.provider }); + } + return secret; +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 67418010e38ec..395afcf56631a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -11,6 +11,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"; @@ -197,7 +198,6 @@ import * as ThreadSettlementService from "./orchestration-v2/ThreadSettlementSer import * as IdleCompactionService from "./orchestration-v2/IdleCompactionService.ts"; import * as ThreadPullRequestService from "./orchestration-v2/ThreadPullRequestService.ts"; import * as RunFinalizationService from "./orchestration-v2/RunFinalizationService.ts"; -import * as EventStoreV2 from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStoreV2 from "./orchestration-v2/ProjectionStore.ts"; import { makePersistedServerRuntimeState } from "./serverRuntimeState.ts"; @@ -372,7 +372,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)); @@ -626,12 +628,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( layerThreadSettlementWorker, layerIdleCompactionWorker, StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), - DatabaseStorage.layer.pipe( - Layer.provide(ProjectionMaintenance.layer), - Layer.provide( - Layer.merge(EventStoreV2.layerFromOrchestrationEventStore, ProjectionStoreV2.layer), - ), - ), + DatabaseStorage.layer.pipe(Layer.provide(ProjectionMaintenance.layer)), layerThreadPullRequestWorker, DelegatedTaskAnalytics.layer, Layer.effectDiscard( diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 6d89f45cb792e..565f8111849e0 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -5,7 +5,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 * 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"; @@ -184,9 +184,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 cc34e2f818381..0e7ce34df0eae 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, @@ -26,6 +27,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SchemaAST from "effect/SchemaAST"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts"; @@ -33,6 +35,7 @@ import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { SECRET_REDACTED } from "./settingsSecrets.ts"; import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; @@ -101,7 +104,37 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi `; }); -it.layer(NodeServices.layer)("server settings", (it) => { +/** Paths of fields typed `SettingsSecretValue` that clients decode with the settings schema itself. */ +function strictSecretReferencePaths( + ast: SchemaAST.AST, + path = "", + seen = new Set(), +): ReadonlyArray { + if (seen.has(ast)) return []; + seen.add(ast); + const walk = (type: SchemaAST.AST, at: string): ReadonlyArray => + type.context?.annotations?.settingsSecretReference === true + ? [at] + : strictSecretReferencePaths(type, `${at}.`, seen); + if (SchemaAST.isObjects(ast)) { + return [ + ...ast.propertySignatures.flatMap((property) => + walk(property.type, `${path}${String(property.name)}`), + ), + ...ast.indexSignatures.flatMap((signature) => walk(signature.type, `${path}*`)), + ]; + } + if (SchemaAST.isArrays(ast)) { + return [...ast.elements, ...ast.rest].flatMap((element) => walk(element, `${path}[]`)); + } + if (SchemaAST.isUnion(ast)) { + return ast.types.flatMap((member) => strictSecretReferencePaths(member, path, seen)); + } + if (SchemaAST.isSuspend(ast)) return strictSecretReferencePaths(ast.thunk(), path, seen); + return []; +} + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings", (it) => { it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; @@ -277,7 +310,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }); assert.deepStrictEqual( - ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + ServerSettingsModule.redactServerSettingsForClient(settings, { secretReferences: true }) + .secretProviders, { personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, vault: { kind: "openbao", label: "Vault" }, @@ -1330,7 +1364,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { "quota-secret", "expo-secret", ]; - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); const disk = yield* fs.readFileString(config.settingsPath); for (const value of values) { assert.include(JSON.stringify(saved), value); @@ -1364,7 +1400,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { instance: { ...client.providerInstances[instanceId]!, config: { apiKey } }, }); assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey); - const redacted = ServerSettingsModule.redactServerSettingsForClient(changed); + const redacted = ServerSettingsModule.redactServerSettingsForClient(changed, { + secretReferences: true, + }); assert.propertyVal( redacted.providerInstances[instanceId]?.config, "apiKey", @@ -1413,9 +1451,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); assert.isTrue(Option.isNone(yield* store.get(secretNames[1]!))); assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).providerInstances[ - instanceId - ]?.environment, + ServerSettingsModule.redactServerSettingsForClient(referenced, { secretReferences: true }) + .providerInstances[instanceId]?.environment, [{ name: "API_TOKEN", value: secret, sensitive: true }], ); yield* service.updateSettings({ @@ -1462,7 +1499,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { "serverPassword", "legacy-password", ); - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); for (const value of ["legacy-api-key", "legacy-password"]) { assert.notInclude(yield* fs.readFileString(config.settingsPath), value); assert.notInclude(JSON.stringify(client), value); @@ -1506,8 +1545,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret); assert.include(raw, "me@example.com"); - const forClient = - ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts; + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }).sourceControlHosts; assert.equal(forClient.bitbucket?.email, "me@example.com"); assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access"); assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0); @@ -1569,15 +1609,78 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); 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; + const redacted = ServerSettingsModule.redactServerSettingsForClient(referenced, { + secretReferences: true, + }).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"); + + // A client that cannot decode references sees saved secrets and can send them back. + const usageReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Hub/key", + }; + const easReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Expo/token", + }; + const hub = UsageLimitSourceId.make("hub"); + const withUsage = yield* serverSettings.updateSettings({ + usageLimitSources: { + [hub]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: usageReference, + enabled: true, + }, + }, + easDeviceHosts: [ + { + kind: "eas", + id: "eas", + label: "EAS Simulator", + accessToken: easReference, + maxSessionMinutes: 30, + idleMinutes: 10, + maxConcurrentSessions: 2, + }, + ], + }); + const legacy = ServerSettingsModule.redactServerSettingsForClient(withUsage, { + secretReferences: false, + }); + assert.notInclude(JSON.stringify(legacy), "op://"); + assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED); + assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED); + assert.equal(legacy.easDeviceHosts[0]?.accessToken, SECRET_REDACTED); + const echoed = yield* serverSettings.updateSettings({ + sourceControlHosts: legacy.sourceControlHosts, + usageLimitSources: legacy.usageLimitSources, + easDeviceHosts: legacy.easDeviceHosts, + }); + assert.deepEqual(echoed.sourceControlHosts.bitbucket?.accessToken, reference); + assert.deepEqual(echoed.sourceControlHosts.github?.tokens, { + "github.com": gitHubReference, + "ghe.acme.test": "ghp_ghe", + }); + assert.deepEqual(echoed.usageLimitSources[hub]?.managementKey, usageReference); + assert.deepEqual(echoed.easDeviceHosts[0]?.accessToken, easReference); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); + it.effect("knows every strictly decoded field that can hold a secret reference", () => + Effect.sync(() => { + // Shipped clients decode these as strings. Each must be covered by the older-client case above. + assert.deepEqual(strictSecretReferencePaths(ServerSettings.ast), [ + "easDeviceHosts.[].accessToken", + "usageLimitSources.*.managementKey", + ]); + }), + ); + it.effect("keeps the retired keys for another load when a token cannot be copied", () => { const cause = new ServerSecretStore.SecretStoreReadError({ resource: "bitbucket-access-token", @@ -2011,643 +2114,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/serverSettings.ts b/apps/server/src/serverSettings.ts index 1ead9e27bfcf5..831a5fad4995b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -18,6 +18,8 @@ import { type ServerSettingsPatch, type ManagedPolicySummary, globalIdleCompaction, + isSecretReference, + type SecretReference, withIdleCompaction, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; @@ -60,7 +62,12 @@ import { sourceControlHostSecretName, } from "./sourceControl/sourceControlHostSecrets.ts"; -import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; +import { + mapSettingsSecrets, + mapSettingsSecretValues, + SECRET_REDACTED, + type SettingsSecret, +} from "./settingsSecrets.ts"; export { resolveReviewGuideModelSelection, @@ -131,13 +138,35 @@ const normalizeServerSettings = ( ), ); -export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { +export function redactServerSettingsForClient( + settings: ServerSettings, + client: { readonly secretReferences: boolean }, +): ServerSettings { const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); - return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; + // Older clients decode secret fields as strings and fail on a reference, so they see a saved secret. + const shaped = client.secretReferences + ? redacted + : mapSettingsSecretValues(redacted, (value) => + isSecretReference(value) ? SECRET_REDACTED : value, + ); + return { ...shaped, secretProviders: withoutUnknownSecretProviderConfigs(shaped) }; +} + +/** A client shown a reference as a saved secret sends that marker back; it keeps the reference. */ +function withEchoedSecretReferences(current: ServerSettings, next: ServerSettings): ServerSettings { + const references = new Map(); + mapSettingsSecretValues(current, (value, secretName) => { + if (isSecretReference(value)) references.set(secretName, value); + return value; + }); + if (references.size === 0) return next; + return mapSettingsSecretValues(next, (value, secretName) => + value === SECRET_REDACTED ? (references.get(secretName) ?? value) : value, + ); } // A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. @@ -1004,7 +1033,7 @@ const make = Effect.gen(function* () { }); const changes: SecretChange[] = []; const nextSecretNames = new Set(); - const settings = mapSettingsSecrets(next, (secret) => { + const settings = mapSettingsSecrets(withEchoedSecretReferences(current, next), (secret) => { const metadata = { secretName: secret.secretName, ...(secret.providerInstanceId !== undefined diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3d4daa659e58a..17640237ff5ca 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -3,7 +3,9 @@ import { ClaudeSettings, CodexSettings, EasDeviceHostConfig, + type ProviderInstanceConfig, type ServerSettings, + type SettingsSecretValue, SimlockDeviceHostConfig, UsageLimitSourceConfig, isSecretReference, @@ -12,6 +14,7 @@ import { import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { cursorClient } from "@t3tools/provider-cursor/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"; @@ -28,7 +31,15 @@ const providerSecretFields = new Map([ ["codex", secretSettingsFields(CodexSettings)], ["claudeAgent", secretSettingsFields(ClaudeSettings)], ["antigravity", secretSettingsFields(AntigravitySettings)], - ...[cursorClient, grokClient, museClient, openCodeClient, piClient, acpRegistryClient].map( + ...[ + cursorClient, + grokClient, + kiroClient, + museClient, + openCodeClient, + piClient, + acpRegistryClient, + ].map( (definition) => [definition.driverKind, secretSettingsFields(definition.settingsSchema)] as const, ), @@ -49,16 +60,19 @@ export interface SettingsSecret { } type SecretValue = Pick; +type SecretSlotName = (field: string, serverHost: string | null) => string; +type SecretFieldsMapper = ( + fields: Readonly>, + secretFields: ReadonlyArray, + name: SecretSlotName, +) => Record; +type ProviderEnvironment = NonNullable; export function mapSettingsSecrets( settings: ServerSettings, map: (secret: SettingsSecret) => SecretValue, ): ServerSettings { - const mapFields = ( - fields: Readonly>, - secretFields: ReadonlyArray, - name: (field: string, serverHost: string | null) => string, - ) => { + const mapFields: SecretFieldsMapper = (fields, secretFields, name) => { const next = { ...fields }; for (const field of secretFields) { if (!(field in next) || isSecretReference(next[field])) continue; @@ -79,6 +93,70 @@ export function mapSettingsSecrets( return next; }; + return mapSecretFieldGroups(settings, mapFields, (instanceId, environment) => + environment.map((variable) => { + const { valueRedacted: _omit, ...rest } = variable; + // A secret reference is not itself secret; storage sees the value cleared. + const reference = typeof variable.value === "string" ? undefined : variable.value; + const result = map({ + secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, + kind: "environment", + value: typeof variable.value === "string" ? variable.value : "", + redacted: reference === undefined && variable.valueRedacted === true, + sensitive: variable.sensitive, + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); + if (reference !== undefined) return { ...rest, value: reference }; + return { + ...rest, + value: result.value, + ...(result.redacted ? { valueRedacted: true } : {}), + }; + }), + ); +} + +/** + * Maps each value a secret field holds, literal or reference, with the secret name of its slot. + * Provider environment variables are left as they are. + */ +export function mapSettingsSecretValues( + settings: ServerSettings, + map: (value: SettingsSecretValue, secretName: string) => SettingsSecretValue, +): ServerSettings { + const mapValue = (value: unknown, secretName: string) => + typeof value === "string" || isSecretReference(value) ? map(value, secretName) : value; + return mapSecretFieldGroups(settings, (fields, secretFields, name) => { + const next = { ...fields }; + for (const field of secretFields) { + if (!(field in next)) continue; + const value = next[field]; + next[field] = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !isSecretReference(value) + ? Object.fromEntries( + Object.entries(value).map(([serverHost, entry]) => [ + serverHost, + mapValue(entry, name(field, serverHost.trim().toLowerCase())), + ]), + ) + : mapValue(value, name(field, null)); + } + return next; + }); +} + +function mapSecretFieldGroups( + settings: ServerSettings, + mapFields: SecretFieldsMapper, + mapEnvironment: (instanceId: string, environment: ProviderEnvironment) => ProviderEnvironment = ( + _instanceId, + environment, + ) => environment, +): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => { const config = instance.config; @@ -98,28 +176,7 @@ export function mapSettingsSecrets( } : {}), ...(instance.environment - ? { - environment: instance.environment.map((variable) => { - const { valueRedacted: _omit, ...rest } = variable; - // A secret reference is not itself secret; storage sees the value cleared. - const reference = typeof variable.value === "string" ? undefined : variable.value; - const result = map({ - secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, - kind: "environment", - value: typeof variable.value === "string" ? variable.value : "", - redacted: reference === undefined && variable.valueRedacted === true, - sensitive: variable.sensitive, - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - if (reference !== undefined) return { ...rest, value: reference }; - return { - ...rest, - value: result.value, - ...(result.redacted ? { valueRedacted: true } : {}), - }; - }), - } + ? { environment: mapEnvironment(instanceId, instance.environment) } : {}), }, ]; 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/SourceControlSignIn.ts b/apps/server/src/sourceControl/SourceControlSignIn.ts index 3dd9ca03e2062..31f7837322fec 100644 --- a/apps/server/src/sourceControl/SourceControlSignIn.ts +++ b/apps/server/src/sourceControl/SourceControlSignIn.ts @@ -28,6 +28,7 @@ export class SourceControlSignIn extends Context.Service< ) => Effect.Effect; readonly access: (input: SourceControlAppAccessInput) => Effect.Effect; readonly starred: (input: SourceControlStarredInput) => Effect.Effect; + readonly star: (input: SourceControlStarredInput) => Effect.Effect; } >()("t3/sourceControl/SourceControlSignIn") {} @@ -70,6 +71,11 @@ const make = Effect.gen(function* () { .isRepositoryStarred; return isStarred === undefined ? false : yield* isStarred(input.host, input.repository); }), + star: Effect.fn("SourceControlSignIn.star")(function* (input) { + const star = drivers.find(({ driver }) => driver.kind === input.provider)?.instance + .starRepository; + return star === undefined ? false : yield* star(input.host, input.repository); + }), }); }); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index be1da6f3d610c..974fc7cc91c49 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { SecretProviderId } from "@t3tools/contracts"; +import { DeviceHostId, SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; @@ -125,12 +125,44 @@ it.effect("reads a collected reference again once it is invalidated", () => }), }); const read = () => resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); + const usageLimitSources = { + [UsageLimitSourceId.make("referenced")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: source("cliproxy#key"), + enabled: true, + }, + [UsageLimitSourceId.make("literal")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: "plain-key", + enabled: true, + }, + } as const; + const easDeviceHosts = [ + { + kind: "eas" as const, + id: DeviceHostId.make("ref"), + label: "Ref", + maxSessionMinutes: 30, + idleMinutes: 10, + maxConcurrentSessions: 2, + accessToken: source("expo#token"), + }, + ]; + cache.set("cliproxy#key", "stale"); + cache.set("expo#token", "stale"); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "stale", }); current = "rotated"; - yield* invalidateSettingsSecrets(resolver, Effect.succeed({ sourceControlHosts: hosts })); + yield* invalidateSettingsSecrets( + resolver, + Effect.succeed({ sourceControlHosts: hosts, usageLimitSources, easDeviceHosts }), + ); + assert.isFalse(cache.has("cliproxy#key")); + assert.isFalse(cache.has("expo#token")); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "rotated", diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 82de25be4de69..21213d27bdb8b 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -97,13 +97,32 @@ export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.reso }, ); +const collectUsageLimitSecretReferences = ( + sources: ServerSettings["usageLimitSources"], +): ReadonlyArray => + Object.values(sources).flatMap(({ managementKey }) => + isSecretReference(managementKey) ? [managementKey] : [], + ); + +const collectEasSecretReferences = ( + hosts: ServerSettings["easDeviceHosts"], +): ReadonlyArray => + hosts.flatMap(({ accessToken }) => (isSecretReference(accessToken) ? [accessToken] : [])); + /** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( resolver: SecretResolver.SecretResolver["Service"], - settings: Effect.Effect, E>, + settings: Effect.Effect< + Pick, + E + >, ) { const references = yield* settings.pipe( - Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), + Effect.map((current) => [ + ...collectSourceControlHostSecretReferences(current.sourceControlHosts), + ...collectUsageLimitSecretReferences(current.usageLimitSources), + ...collectEasSecretReferences(current.easDeviceHosts), + ]), Effect.orElseSucceed(() => undefined), ); yield* resolver.invalidate(references); 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/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 2b08e4f51e5a5..05b8ea8c46387 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 { @@ -406,7 +407,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 d3ffc8c91d03e..db0bda6a4bd53 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 f7a93f24a6641..14d12c0af9e15 100644 --- a/apps/server/src/triage/TriageService.ts +++ b/apps/server/src/triage/TriageService.ts @@ -23,7 +23,8 @@ const T3CODE_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; 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/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index b8976fca81e7d..7b078b7b61202 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,10 +1,12 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -36,6 +38,10 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => ); }).pipe( Effect.provide([ + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => Effect.succeed(references.map(() => undefined)), + invalidate: () => Effect.void, + }), ServerSettings.layerTest({ usageLimitSources: { [UsageLimitSourceId.make("local-hub")]: { @@ -83,3 +89,69 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => Effect.scoped, ), ); + +it.effect("reads a management key from its secret provider", () => { + const authorizations: Array = []; + const keySource = (reference: string) => ({ + provider: SecretProviderId.make("homelab"), + reference, + }); + return Effect.gen(function* () { + const sources = yield* UsageLimitSources.make; + const snapshots = yield* sources.streamChanges.pipe( + Stream.filter((next) => next.length > 0), + Stream.runHead, + Effect.flatMap(Effect.fromOption), + ); + expect(snapshots.map(({ id, error }) => ({ id, error }))).toEqual([ + { id: "resolved-hub", error: undefined }, + { + id: "unresolved-hub", + error: "Could not read the management key from its secret provider.", + }, + ]); + expect(authorizations).toEqual(["Bearer resolved-key"]); + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("resolved-hub")]: { + kind: "cliproxy", + url: "https://resolved.test", + managementKey: keySource("cliproxy#key"), + enabled: true, + }, + [UsageLimitSourceId.make("unresolved-hub")]: { + kind: "cliproxy", + url: "https://unresolved.test", + managementKey: keySource("missing#key"), + enabled: true, + }, + }, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Effect.succeed( + references.map((reference) => + reference.reference === "cliproxy#key" ? "resolved-key" : undefined, + ), + ), + invalidate: () => Effect.void, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + authorizations.push(request.headers.authorization); + return HttpClientResponse.fromWeb(request, Response.json({ files: [] })); + }), + ), + ), + NodeCrypto.layer, + ]), + Effect.scoped, + ); +}); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7a068cde85f6c..7d40b0fb9ecea 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -4,7 +4,8 @@ import { type UsageLimitSourceConsumeResetCreditInput, type ProviderConsumeResetCreditResult, type ServerSettings, - type UsageLimitSourceConfig, + UsageLimitSourceConfig, + settingsSecretLiteral, type UsageLimitSourceId, type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; @@ -21,6 +22,9 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; +import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import * as Settings from "../serverSettings.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; @@ -46,11 +50,24 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { } } +function missingManagementKey(config: UsageLimitSourceConfig): string { + return typeof config.managementKey === "string" + ? "No management key configured." + : "Could not read the management key from its secret provider."; +} + /** @public */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* Settings.ServerSettingsService; + const secrets = yield* SecretResolver.SecretResolver; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* ( + config: UsageLimitSourceConfig, + ) { + const resolved = yield* resolveSettingsSecrets(secrets, UsageLimitSourceConfig, config); + return settingsSecretLiteral(resolved.managementKey); + }); const stateRef = yield* Ref.make>([]); const changes = yield* Effect.acquireRelease( PubSub.unbounded>(), @@ -63,10 +80,13 @@ export const make = Effect.gen(function* () { ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; - if (config.managementKey.length === 0) { - return { ...base, accounts: [], error: "No management key configured." }; + const managementKey = yield* readManagementKey(config); + if (managementKey.length === 0) { + return { ...base, accounts: [], error: missingManagementKey(config) }; } - const accounts = yield* api.readAccounts(config).pipe(Effect.result); + const accounts = yield* api + .readAccounts({ url: config.url, managementKey }) + .pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); return { ...base, accounts: [], error: accounts.failure.detail }; @@ -108,12 +128,20 @@ export const make = Effect.gen(function* () { Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })), ); const config = settings.usageLimitSources[input.sourceId]; - if (!config?.enabled || !config.managementKey) { + if (!config?.enabled) { return yield* new UsageLimitSourceError({ detail: "The usage limit source is missing or disabled.", }); } - const result = yield* api.consume(config, input.accountId, input.creditId); + const managementKey = yield* readManagementKey(config); + if (managementKey.length === 0) { + return yield* new UsageLimitSourceError({ detail: missingManagementKey(config) }); + } + const result = yield* api.consume( + { url: config.url, managementKey }, + input.accountId, + input.creditId, + ); const snapshot = yield* readSource(input.sourceId, config); const previous = yield* Ref.get(stateRef); yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); @@ -167,4 +195,6 @@ export const make = Effect.gen(function* () { } satisfies UsageLimitSources["Service"]; }); -export const layer = Layer.effect(UsageLimitSources, make); +export const layer = Layer.effect(UsageLimitSources, make).pipe( + Layer.provide(SecretResolverLayer.layer), +); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 8c09383d56c18..9cf97cd4c7c4d 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"; @@ -149,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)), @@ -158,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)), @@ -239,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 }; @@ -286,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; @@ -1512,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 () => { @@ -1546,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", () => @@ -2409,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* () { @@ -2476,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; @@ -2508,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, @@ -2535,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/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index eb956a67a31d0..4fb59d041c3d3 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"; @@ -18,6 +18,12 @@ import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +/** A hub with its management key already read from wherever the setting keeps it. */ +export interface CliproxyHub { + readonly url: UsageLimitSourceConfig["url"]; + readonly managementKey: string; +} + const AuthFile = Schema.Struct({ id: Schema.String, auth_index: Schema.String, @@ -156,12 +162,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,10 +176,10 @@ 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, + hub: CliproxyHub, path: string, body?: unknown, ) { @@ -181,13 +187,13 @@ export const makeCliproxyApi = Effect.gen(function* () { try: () => new URL( `v0/management/${path}`, - config.url.endsWith("/") ? config.url : `${config.url}/`, + hub.url.endsWith("/") ? hub.url : `${hub.url}/`, ).toString(), catch: () => new UsageLimitSourceError({ detail: "The hub URL is not valid." }), }); const request = ( body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url) - ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${config.managementKey}`)); + ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${hub.managementKey}`)); const response = yield* client .execute(body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body))) .pipe( @@ -201,13 +207,13 @@ export const makeCliproxyApi = Effect.gen(function* () { return response; }); - const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) { - const response = yield* management(config, "auth-files"); + const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (hub: CliproxyHub) { + const response = yield* management(hub, "auth-files"); return (yield* decodeAuthFiles(response)).files; }); const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, url: string, data?: unknown, @@ -229,7 +235,7 @@ export const makeCliproxyApi = Effect.gen(function* () { // The OAuth usage endpoint can return 429 without a Claude Code user agent. "User-Agent": "claude-code/2.1.273", }; - const raw = yield* management(config, "api-call", { + const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, method: data === undefined ? "GET" : "POST", url, @@ -246,10 +252,10 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const credits = Effect.fn("CliproxyApi.credits")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, ) { - const body = yield* apiCall(config, account, CREDIT_URL); + const body = yield* apiCall(hub, account, CREDIT_URL); const response = yield* decodeCreditList(body); const now = DateTime.toEpochMillis(yield* DateTime.now); return response.credits @@ -263,7 +269,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -277,7 +283,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage"); + const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); const usage = yield* decodeClaudeUsage(body); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" @@ -309,7 +315,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }).limits, }; } - const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`); + const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`); const usage = yield* decodeCodexUsage(body); const toWindow = (window: typeof CodexWindow.Type | null | undefined) => window @@ -321,7 +327,7 @@ export const makeCliproxyApi = Effect.gen(function* () { : { windowDurationMins: window.limit_window_seconds / 60 }), } : null; - const available = yield* credits(config, account).pipe(Effect.orElseSucceed(() => undefined)); + const available = yield* credits(hub, account).pipe(Effect.orElseSucceed(() => undefined)); const next = available?.[0]; const planType = usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; @@ -366,9 +372,9 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, ): Effect.fn.Return, UsageLimitSourceError> { - const accounts = yield* authFiles(config).pipe( + const accounts = yield* authFiles(hub).pipe( Effect.mapError((error) => isUsageLimitSourceError(error) ? error @@ -380,28 +386,28 @@ export const makeCliproxyApi = Effect.gen(function* () { (account) => !account.disabled && (account.provider === "codex" || account.provider === "claude"), ), - (account) => readAccount(config, account), + (account) => readAccount(hub, account), { concurrency: 4 }, ); }); const consume = Effect.fn("CliproxyApi.consume")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, accountId: string, creditId: string, ): Effect.fn.Return { const operation = Effect.gen(function* () { - const account = (yield* authFiles(config)).find((account) => account.id === accountId); + const account = (yield* authFiles(hub)).find((account) => account.id === accountId); if (!account || account.disabled || account.provider !== "codex") { return yield* new UsageLimitSourceError({ detail: "The Codex hub account is missing or disabled.", }); } - const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, { + const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { 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); @@ -414,7 +420,7 @@ export const makeCliproxyApi = Effect.gen(function* () { } as const )[response.code]; if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; - const cleared = yield* management(config, "reset-quota", { + const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index, }).pipe(Effect.result); return { 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 9f9d23a8e409b..7c9e764f441b3 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,34 +2849,61 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reads range patches from source without running textconv", () => + it.effect("reads raw range context regardless of Git display configuration", () => 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, ["config", "diff.relative", "true"]); + yield* git(cwd, ["config", "log.decorate", "short"]); yield* git(cwd, ["checkout", "-b", "feature/range"]); yield* writeTextFile(cwd, ".gitattributes", "*.doc diff=probe\n"); yield* writeTextFile(cwd, "notes.doc", "source line\n"); yield* writeTextFile(cwd, "plain.txt", "plain line\n"); yield* writeTextFile(cwd, "binary.dat", "binary\0data"); yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "range files"]); + const signingKey = `${yield* makeTmpDir("git-vcs-driver-signing-key-")}/key`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const keygen = ChildProcess.make("ssh-keygen", [ + "-q", + "-t", + "ed25519", + "-N", + "", + "-f", + signingKey, + ]); + assert.equal(Number(yield* spawner.exitCode(keygen)), 0); + yield* git(cwd, ["config", "gpg.format", "ssh"]); + // A global signer such as 1Password's cannot use the fixture's generated key. + yield* git(cwd, ["config", "gpg.ssh.program", "ssh-keygen"]); + yield* git(cwd, ["config", "user.signingkey", `${signingKey}.pub`]); + yield* git(cwd, ["config", "log.showSignature", "true"]); + yield* git(cwd, ["commit", "-S", "-m", "range files"]); const driver = yield* GitVcsDriver.GitVcsDriver; const fileSystem = yield* FileSystem.FileSystem; + const projectCwd = `${cwd}/app`; + yield* fileSystem.makeDirectory(projectCwd); - const context = yield* driver.readRangeContext(cwd, initialBranch); + const context = yield* driver.readRangeContext(projectCwd, initialBranch); + assert.match(context.commitSummary.trim(), /^[a-f0-9]+ range files$/); assert.include(context.diffPatch, "+source line"); assert.notInclude(context.diffPatch, "converted"); 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"]); + const staged = yield* driver.prepareCommitContext(projectCwd, ["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 +3121,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 +3331,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 +3563,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 +4304,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 +4371,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 +4421,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 +5182,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 +6397,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..f8b59f389567b 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) { @@ -2516,7 +2525,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedSummary = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedSummary", cwd, - ["diff", "--cached", "--name-status"], + // cwd can be a subdirectory; diff.relative would hide changes outside it. + ["-c", "diff.relative=false", "diff", "--cached", "--name-status"], { env, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe(Effect.map((stdout) => stdout.trim())); if (stagedSummary.length === 0) { @@ -2529,7 +2539,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedPatch = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, - ["diff", "--no-ext-diff", "--no-textconv", "--cached", "--patch", "--minimal"], + [ + "-c", + "diff.relative=false", + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--cached", + "--patch", + "--minimal", + ], { env, maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, @@ -2922,7 +2942,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", "--end-of-options", commitRange], + [ + "log", + "--oneline", + "--no-color", + "--no-decorate", + "--no-show-signature", + "--end-of-options", + commitRange, + ], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2931,7 +2959,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", "--end-of-options", diffRange], + [ + "-c", + "diff.relative=false", + "diff", + "--stat", + "--no-color", + "--end-of-options", + diffRange, + ], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2941,7 +2977,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.readRangeContext.diffPatch", cwd, [ + "-c", + "diff.relative=false", "diff", + "--no-color", "--no-ext-diff", "--no-textconv", "--patch", @@ -2974,7 +3013,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 +3192,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 +3398,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 +3422,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 +4203,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 +4257,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 +4325,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 +4352,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 d5539e2f4e718..8b8782b200504 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -505,7 +505,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.eventHistoryRetention.test.ts b/apps/server/src/ws.eventHistoryRetention.test.ts deleted file mode 100644 index b94ef55ac39f0..0000000000000 --- a/apps/server/src/ws.eventHistoryRetention.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { ProjectId, ThreadId } from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as PubSub from "effect/PubSub"; -import * as Stream from "effect/Stream"; -import * as SqlClient from "effect/sql/SqlClient"; -import * as Statement from "effect/sql/Statement"; - -import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; -import * as ProjectService from "./project/ProjectService.ts"; -import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; -import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; -import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; -import * as SqlitePersistence from "./persistence/Sqlite.ts"; -import { subscribeOrchestrationV2Shell } from "./ws.ts"; - -const ReplayTestLayer = OrchestrationEventStore.layer.pipe( - Layer.provideMerge(SqlitePersistence.layerMemory), -); - -it.effect("shell resume below the history replay floor sends a snapshot", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const now = "2026-01-05T00:00:00.000Z"; - const projectId = ProjectId.make("project:retention-floor"); - const insert = (version: number) => sql<{ readonly 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 ( - ${`retention-floor:${version}`}, 'project', ${projectId}, ${version}, 'project.meta-updated', - ${now}, 'server', ${JSON.stringify({ projectId, updatedAt: now })}, '{}', 2 - ) RETURNING sequence - `; - const [before] = yield* insert(0); - const [after] = yield* insert(1); - const resume = subscribeOrchestrationV2Shell({ - afterSequence: before!.sequence, - requestCompletionMarker: true, - }).pipe( - Effect.flatMap((stream) => - stream.pipe( - Stream.takeUntil((item) => item.kind === "synchronized"), - Stream.map((item) => item.kind as string), - Stream.runCollect, - ), - ), - Effect.provideService(ThreadManagement.ThreadManagementService, { - listActiveShellIndex: () => Effect.succeed([]), - readShellSnapshot: () => Effect.succeed(Effect.succeed({ schemaVersion: 1, threads: [] })), - listActiveSubagentThreadIds: () => Effect.succeed([]), - } 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.succeedNone, - } as unknown as ProjectService.ProjectService["Service"]), - Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { - subscribeChanges: PubSub.subscribe(yield* PubSub.unbounded()), - } as ProjectEnrichment.ProjectEnrichmentService["Service"]), - ); - assert.deepEqual(Array.from(yield* resume), ["project.removed", "synchronized"]); - yield* sql` - INSERT INTO orchestration_v2_event_retention (thread_id, replay_floor) - VALUES ('thread:pruned', ${after!.sequence}) - `; - assert.deepEqual(Array.from(yield* resume), ["snapshot", "synchronized"]); - }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), -); - -it.effect("shell resume falls back when retention commits during the replay read", () => - Effect.scoped( - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const now = "2026-01-05T00:00:00.000Z"; - const projectId = ProjectId.make("project:retention-race"); - const threadId = ThreadId.make("thread:retention-race"); - const insertProject = (version: number) => sql<{ readonly 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 ( - ${`retention-race:project:${version}`}, 'project', ${projectId}, ${version}, 'project.meta-updated', - ${now}, 'server', ${JSON.stringify({ projectId, updatedAt: now })}, '{}', 2 - ) RETURNING sequence - `; - const [before] = yield* insertProject(0); - const [pruned] = yield* sql<{ readonly 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 ( - 'retention-race:thread', 'thread', ${threadId}, 0, 'thread.metadata-updated', - ${now}, 'server', '{}', '{}', 2 - ) RETURNING sequence - `; - yield* insertProject(1); - - const replayReadStarted = yield* Deferred.make(); - const releaseReplayRead = yield* Deferred.make(); - let blocked = false; - const blockReplayRead: Statement.Transformer = (statement) => { - const [query] = statement.compile(); - if ( - !blocked && - query.includes("FROM orchestration_events") && - query.includes("ORDER BY sequence ASC") - ) { - blocked = true; - return Deferred.succeed(replayReadStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseReplayRead)), - Effect.as(statement), - ); - } - return Effect.succeed(statement); - }; - const resume = subscribeOrchestrationV2Shell({ - afterSequence: before!.sequence, - requestCompletionMarker: true, - }).pipe( - Effect.flatMap((stream) => - stream.pipe( - Stream.takeUntil((item) => item.kind === "synchronized"), - Stream.map((item) => item.kind as string), - Stream.runCollect, - ), - ), - Effect.provideService(ThreadManagement.ThreadManagementService, { - listActiveShellIndex: () => Effect.succeed([]), - readShellSnapshot: () => - Effect.succeed(Effect.succeed({ schemaVersion: 1, threads: [] })), - listActiveSubagentThreadIds: () => Effect.succeed([]), - } 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.succeedNone, - } as unknown as ProjectService.ProjectService["Service"]), - Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { - subscribeChanges: PubSub.subscribe(yield* PubSub.unbounded()), - } as ProjectEnrichment.ProjectEnrichmentService["Service"]), - Effect.provideService(Statement.CurrentTransformer, blockReplayRead), - ); - const resumedFiber = yield* Effect.forkScoped(resume); - yield* Deferred.await(replayReadStarted); - yield* sql.withTransaction( - Effect.gen(function* () { - yield* sql`DELETE FROM orchestration_events WHERE sequence = ${pruned!.sequence}`; - yield* sql` - INSERT INTO orchestration_v2_event_retention (thread_id, replay_floor) - VALUES (${threadId}, ${pruned!.sequence}) - `; - }), - ); - yield* Deferred.succeed(releaseReplayRead, undefined); - - assert.deepEqual(Array.from(yield* Fiber.join(resumedFiber)), ["snapshot", "synchronized"]); - }).pipe(Effect.provide(ReplayTestLayer)), - ), -); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index caa06c2e97a80..d1d0d36414ab8 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,4 +1,5 @@ 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 { @@ -21,6 +22,8 @@ import { import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Cause from "effect/Cause"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; @@ -30,6 +33,7 @@ import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; @@ -114,9 +118,6 @@ import * as Keybindings from "./keybindings.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; -import * as EventStore from "./orchestration-v2/EventStore.ts"; -import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; -import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; @@ -424,6 +425,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 = ( @@ -617,7 +743,7 @@ 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([ @@ -760,6 +886,7 @@ it.effect.each([ {}, yield* ServerBrowser.ServerBrowser, () => Effect.void, + { secretReferences: true }, ).pipe(Layer.provide(configRpcServices)), ), ); @@ -804,8 +931,8 @@ it.effect.each([ key: "mod+alt+z", command: "terminal.split", }); - const expectedSettings = [duringDiscovery, latestSettings].map( - ServerSettings.redactServerSettingsForClient, + const expectedSettings = [duringDiscovery, latestSettings].map((value) => + ServerSettings.redactServerSettingsForClient(value, { secretReferences: true }), ); const expectedKeybindings = [duringAck.keybindings, latestKeybindings.keybindings]; // The two sources can interleave, but each must retain its own publication order. @@ -833,7 +960,7 @@ it.effect.each([ assert.deepEqual(resolved.config.remoteOpenTargets, targets); assert.deepEqual( resolved.config.settings, - ServerSettings.redactServerSettingsForClient(latestSettings), + ServerSettings.redactServerSettingsForClient(latestSettings, { secretReferences: true }), ); assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings); yield* acknowledge; @@ -848,7 +975,9 @@ it.effect.each([ assert.deepEqual(resolved.config.directEndpoints, endpoints); assert.deepEqual( resolved.config.settings, - ServerSettings.redactServerSettingsForClient(latestSettings), + ServerSettings.redactServerSettingsForClient(latestSettings, { + secretReferences: true, + }), ); assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings); yield* acknowledge; @@ -978,6 +1107,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( {}, yield* ServerBrowser.ServerBrowser, () => Effect.void, + { secretReferences: true }, ).pipe(Layer.provide(configRpcServices)), ), ); @@ -1035,93 +1165,176 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( ), ); -// Compaction keeps the oldest and newest event per message, so a cursor older -// than the deleted rows still replays each message where the client would -// first have seen it (reducers append on first sight) and reaches the same -// final state. No snapshot fallback is needed. -it.effect("thread resume across compacted history keeps message order and final state", () => +// Pruned history cannot be replayed: a cursor below the boundary must resync from a snapshot. +it.effect.each(["thread", "shell"] as const)( + "%s resume from a cursor below the pruned boundary loads a snapshot", + (scope) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-10-13T00:00:00.000Z"; + const threadId = ThreadId.make("thread:pruned"); + const insert = (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 ( + ${`pruned:${version}`}, 'thread', ${threadId}, ${version}, 'provider-session.detached', + ${now}, 'server', ${JSON.stringify({ providerSessionId: "session:pruned", detachedAt: now })}, + '{}', 2 + ) RETURNING sequence + `; + const [first] = yield* insert(0); + const [second] = yield* insert(1); + yield* insert(2); + yield* sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${second!.sequence}, ${now}) + `; + const subscription = Effect.gen(function* () { + const stream = + scope === "thread" + ? yield* subscribeOrchestrationV2Thread({ threadId, afterSequence: first!.sequence }) + : yield* subscribeOrchestrationV2Shell({ afterSequence: first!.sequence }); + return stream; + }); + const exit = yield* subscription.pipe( + Effect.provideService(ThreadManagement.ThreadManagementService, { + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: () => Effect.die("snapshot"), + listActiveShellIndex: () => Effect.die("snapshot"), + readShellSnapshot: () => Effect.die("snapshot"), + listActiveSubagentThreadIds: () => Effect.die("replay"), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService( + ProjectService.ProjectService, + {} as ProjectService.ProjectService["Service"], + ), + Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { + subscribeChanges: PubSub.subscribe(yield* PubSub.unbounded()), + } as ProjectEnrichment.ProjectEnrichmentService["Service"]), + Effect.exit, + ); + assert.deepEqual(Exit.isFailure(exit) ? Cause.squash(exit.cause) : exit, "snapshot"); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + +// A prune pass can delete replayed rows after the boundary check; the read must not be trusted. +it.effect.each(["thread", "shell"] as const)( + "%s resume loads a snapshot when pruning advances during the replay read", + (scope) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-10-13T00:00:00.000Z"; + const threadId = ThreadId.make("thread:prune-race"); + const insert = (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 ( + ${`prune-race:${version}`}, 'thread', ${threadId}, ${version}, 'provider-session.detached', + ${now}, 'server', ${JSON.stringify({ providerSessionId: "session:race", detachedAt: now })}, + '{}', 2 + ) RETURNING sequence + `; + const [first] = yield* insert(0); + const [second] = yield* insert(1); + yield* insert(2); + + const readStarted = yield* Deferred.make(); + const releaseRead = yield* Deferred.make(); + let gated = false; + const gateReplayRead: Statement.Transformer = (statement) => { + const [query] = statement.compile(); + // The paged replay read (readApplicationRows), not the stats or high-water queries. + if (!gated && /^\s*SELECT\s+sequence,\s+event_id,/.test(query)) { + gated = true; + return Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRead)), + Effect.as(statement), + ); + } + return Effect.succeed(statement); + }; + const subscription = Effect.gen(function* () { + const stream = + scope === "thread" + ? yield* subscribeOrchestrationV2Thread({ threadId, afterSequence: first!.sequence }) + : yield* subscribeOrchestrationV2Shell({ afterSequence: first!.sequence }); + return stream; + }).pipe( + Effect.provideService(Statement.CurrentTransformer, gateReplayRead), + Effect.provideService(ThreadManagement.ThreadManagementService, { + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: () => Effect.die("snapshot"), + listActiveShellIndex: () => Effect.die("snapshot"), + readShellSnapshot: () => Effect.die("snapshot"), + listActiveSubagentThreadIds: () => Effect.succeed([]), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService( + ProjectService.ProjectService, + {} as ProjectService.ProjectService["Service"], + ), + Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { + subscribeChanges: PubSub.subscribe(yield* PubSub.unbounded()), + } as ProjectEnrichment.ProjectEnrichmentService["Service"]), + Effect.exit, + Effect.forkChild, + ); + const fiber = yield* subscription; + yield* Deferred.await(readStarted); + // A pass publishes its boundary, then deletes the rows it covers. + yield* sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${second!.sequence}, ${now}) + `; + yield* sql`DELETE FROM orchestration_events WHERE sequence = ${second!.sequence}`; + yield* Deferred.succeed(releaseRead, undefined); + const exit = yield* Fiber.join(fiber); + assert.deepEqual(Exit.isFailure(exit) ? Cause.squash(exit.cause) : exit, "snapshot"); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + +// A thread idle since before the boundary missed nothing; resuming must not resend its body. +it.effect("thread resume at its own high-water below the pruned boundary skips the snapshot", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; const now = "2026-10-13T00:00:00.000Z"; - const threadId = ThreadId.make("thread:compacted"); - const insertMessage = (version: number, id: string, text: string) => sql<{ - sequence: number; - }>` + const threadId = ThreadId.make("thread:idle"); + const [last] = yield* 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 ( - ${`compacted:${version}`}, 'thread', ${threadId}, ${version}, 'message.updated', - ${now}, 'server', ${JSON.stringify({ - createdBy: "user", - creationSource: "web", - id, - threadId, - runId: null, - nodeId: null, - role: "user", - text, - attachments: [], - streaming: false, - createdAt: now, - updatedAt: now, - })}, '{}', 2 + 'idle:0', 'thread', ${threadId}, 0, 'provider-session.detached', + ${now}, 'server', ${JSON.stringify({ providerSessionId: "session:idle", detachedAt: now })}, + '{}', 2 ) RETURNING sequence `; - const [first] = yield* insertMessage(0, "message:a", "a created"); - const [second] = yield* insertMessage(1, "message:b", "b created"); - yield* insertMessage(2, "message:a", "a superseded"); - const [head] = yield* insertMessage(3, "message:a", "a final"); - // thread.deleted is never superseded: clients switch on the event type. - for (const version of [0, 1]) { - yield* sql` - 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 ( - ${`deleted:${version}`}, 'thread', 'thread:deleted-twice', ${version}, 'thread.deleted', - ${now}, 'server', '{}', '{}', 2 - ) - `; - } - assert.equal((yield* maintenance.compactEventStore()).deletedEventCount, 1); - - const items = yield* subscribeOrchestrationV2Thread({ + yield* sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${last!.sequence + 10}, ${now}) + `; + const exit = yield* subscribeOrchestrationV2Thread({ threadId, - afterSequence: first!.sequence - 1, - requestCompletionMarker: true, + afterSequence: last!.sequence, }).pipe( - Effect.flatMap((stream) => - stream.pipe( - Stream.takeUntil((item) => item.kind === "synchronized"), - Stream.runCollect, - ), - ), Effect.provideService(ThreadManagement.ThreadManagementService, { ensureLegacyTranscript: () => Effect.void, - streamStoredEventsFrom: () => Stream.never, + getThreadSnapshot: () => Effect.die("snapshot"), + streamStoredEventsFrom: () => Stream.empty, } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.exit, ); - assert.deepEqual( - Array.from(items, (item) => - item.kind === "event" - ? [item.sequence, item.event.type === "message.updated" ? item.event.payload.text : null] - : [item.kind], - ), - [ - [first!.sequence, "a created"], - [second!.sequence, "b created"], - [head!.sequence, "a final"], - ["synchronized"], - ], - ); - }).pipe( - Effect.provide( - ProjectionMaintenance.layer.pipe( - Layer.provide(Layer.merge(EventStore.layer, ProjectionStore.layer)), - Layer.provideMerge(ReplayTestLayer), - ), - ), - ), + // A snapshot load dies with "snapshot", so success means the resume replayed nothing. + assert.isTrue(Exit.isSuccess(exit)); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 32a4f4141b1d0..2bd0acc22b8c3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,6 +1,6 @@ import { OrchestrationDispatchCommandError } 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"; @@ -55,6 +55,8 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_V2_WS_METHODS, ORCHESTRATION_PROTOCOL_QUERY_PARAM, + CLIENT_CAPABILITIES_QUERY_PARAM, + SETTINGS_SECRET_REFERENCES_CAPABILITY, ORCHESTRATION_PROTOCOL_VERSION, OrchestrationV2DispatchCommandError, OrchestrationV2GetShellSnapshotError, @@ -173,9 +175,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 { makeProviderInstallation } from "./provider/providerInstallation.ts"; @@ -271,6 +270,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); @@ -697,6 +697,11 @@ function hasCompatibleOrchestrationProtocol(url: URL): boolean { ); } +function readSettingsClient(url: URL) { + const capabilities = url.searchParams.get(CLIENT_CAPABILITIES_QUERY_PARAM)?.split(",") ?? []; + return { secretReferences: capabilities.includes(SETTINGS_SECRET_REFERENCES_CAPABILITY) }; +} + function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; readonly acceptDeferredWork?: boolean; @@ -706,7 +711,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 {}; } @@ -756,7 +761,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 []; } @@ -782,9 +787,7 @@ const canReplayPersistedRange = Effect.fnUntraced(function* ( if (replayGap < 0 || replayGap > maxGap) { return false; } - if ((yield* applicationEvents.replayFloor()) > afterSequence) { - return false; - } + if (afterSequence < (yield* applicationEvents.prunedThroughSequence)) return false; const stats = yield* applicationEvents.getReplayStats({ afterSequence, throughSequence: headSequence, @@ -973,36 +976,21 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs }), ), ); - if (input.afterSequence > highWater) { - return yield* snapshotThenLive(); - } - const threadShellExists = Effect.suspend(() => - threadManagement.getThreadShell(input.threadId), - ).pipe( - Effect.map((shell) => shell !== null), + const prunedThrough = yield* applicationEvents.prunedThroughSequence.pipe( Effect.mapError( (cause) => new OrchestrationV2GetThreadProjectionError({ threadId: input.threadId, - message: `Failed to locate orchestration V2 thread ${input.threadId}`, - cause, - }), - ), - ); - const resumeAfter = input.afterSequence; - const replayExpired = applicationEvents.replayFloor(input.threadId).pipe( - Effect.map((floor) => floor > resumeAfter), - Effect.mapError( - (cause) => - new OrchestrationV2GetThreadProjectionError({ - threadId: input.threadId, - message: `Failed to read orchestration V2 thread ${input.threadId} replay floor`, + message: `Failed to prepare orchestration V2 thread ${input.threadId} replay`, cause, }), ), ); - const expiredBeforeReplay = yield* replayExpired; - if (expiredBeforeReplay && (yield* threadShellExists)) { + // An idle thread's cursor sits at its own high-water: nothing was missed, so replay nothing. + if ( + input.afterSequence > highWater || + (input.afterSequence < highWater && input.afterSequence < prunedThrough) + ) { return yield* snapshotThenLive(); } const stats = yield* applicationEvents @@ -1029,16 +1017,39 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ) { return yield* snapshotThenLive(); } - if (stats.hasCreateEvent && (yield* threadShellExists)) { + if (stats.hasCreateEvent) { + const shell = yield* threadManagement.getThreadShell(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed to locate recreated orchestration V2 thread ${input.threadId}`, + cause, + }), + ), + ); // Retained creation events may describe deleted threads; replace replay only if a snapshot exists. - return yield* snapshotThenLive(); + if (shell !== null) return yield* snapshotThenLive(); } - const replay = coalesceLiveUpdatedEvents( - yield* loadReplayThrough(input.afterSequence, highWater), - ); - if (!expiredBeforeReplay && (yield* replayExpired) && (yield* threadShellExists)) { + const replayed = yield* loadReplayThrough(input.afterSequence, highWater); + // Pruning publishes its boundary before deleting, so a pass during the read shows up here. + if ( + input.afterSequence < highWater && + input.afterSequence < + (yield* applicationEvents.prunedThroughSequence.pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed to prepare orchestration V2 thread ${input.threadId} replay`, + cause, + }), + ), + )) + ) { return yield* snapshotThenLive(); } + const replay = coalesceLiveUpdatedEvents(replayed); const plan = decideThreadResume({ afterSequence: input.afterSequence, highWater, @@ -1146,14 +1157,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 }, @@ -1240,26 +1274,33 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc Stream.concat(completionMarker, liveFrom(afterSequence)); const stream = yield* Effect.gen(function* () { - const snapshotThenLive = Effect.gen(function* () { + if (input.afterSequence === undefined) { const loaded = yield* loadSnapshot(); return composeShellStreamWithEnrichment({ initial: initialSnapshotItems(loaded), tail: completionThenLive(loaded.snapshot.snapshotSequence), enrichment: enrichmentRefreshes, }); - }); - if (input.afterSequence === undefined) { - return yield* snapshotThenLive; } + const fromSnapshot = Effect.gen(function* () { + const loaded = yield* loadSnapshot(); + return composeShellStreamWithEnrichment({ + initial: initialSnapshotItems(loaded), + tail: completionThenLive(loaded.snapshot.snapshotSequence), + enrichment: enrichmentRefreshes, + }); + }); const highWater = yield* applicationEvents.latestApplicationSequence; if (!(yield* canReplayPersistedRange(input.afterSequence, highWater, SHELL_RESUME_MAX_GAP))) { - return yield* snapshotThenLive; + return yield* fromSnapshot; } subagentThreadIds = new Set(yield* threadManagement.listActiveSubagentThreadIds()); const loaded = yield* loadProjectMetadataSnapshot(highWater); + // Read before replying: the range is budgeted above, and a prune pass during the read + // publishes its boundary first, so the recheck below catches deleted rows. const replayed = yield* applicationEvents .readApplicationEvents({ afterSequence: input.afterSequence, @@ -1270,8 +1311,8 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc skipUnknownEventTypes: true, }) .pipe(Stream.filter(filterSubagentTranscript), Stream.runCollect); - if ((yield* applicationEvents.replayFloor()) > input.afterSequence) { - return yield* snapshotThenLive; + if (input.afterSequence < (yield* applicationEvents.prunedThroughSequence)) { + return yield* fromSnapshot; } const replay = toShellStream(Stream.fromIterable(replayed)); return composeShellStreamWithEnrichment({ @@ -1307,6 +1348,7 @@ export const layerWsRpc = ( clientAnalyticsProps: Readonly>, serverBrowser: ServerBrowser.ServerBrowser["Service"], reportProfiles: ServerBrowser.ProfileReporter, + settingsClient: { readonly secretReferences: boolean }, ) => Layer.effectContext( Effect.gen(function* () { @@ -1363,13 +1405,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}`)), ); @@ -1406,8 +1448,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 = @@ -1606,7 +1646,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({ @@ -1822,6 +1862,7 @@ export const layerWsRpc = ( : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, + settingsClient, ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -2377,38 +2418,19 @@ 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, + }); + } + if (input.reloadSecrets === true && input.instanceId === undefined) { + yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } - if (input.reloadSecrets === true && input.instanceId === undefined) { - yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); - } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, @@ -2616,7 +2638,7 @@ export const layerWsRpc = ( const settings = yield* providerInstanceMutation === undefined ? serverSettings.updateSettings(nextPatch) : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); - return ServerSettings.redactServerSettingsForClient(settings); + return ServerSettings.redactServerSettingsForClient(settings, settingsClient); }), [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, [WS_METHODS.serverGetTraceDiagnostics]: (_input) => @@ -2774,6 +2796,7 @@ export const layerWsRpc = ( [WS_METHODS.sourceControlSignInCancel]: (input) => sourceControlSignIn.cancel(input), [WS_METHODS.sourceControlAppAccess]: (input) => sourceControlSignIn.access(input), [WS_METHODS.sourceControlStarred]: (input) => sourceControlSignIn.starred(input), + [WS_METHODS.sourceControlStar]: (input) => sourceControlSignIn.star(input), [WS_METHODS.projectCloneStart]: (input) => projectCloneTracker.start(input, { createProject: (project) => @@ -3274,7 +3297,9 @@ export const layerWsRpc = ( ) : Stream.empty; const settingsUpdates = settingsChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), + Stream.map((settings) => + ServerSettings.redactServerSettingsForClient(settings, settingsClient), + ), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, @@ -3404,8 +3429,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", @@ -3461,7 +3486,13 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc(session, clientAnalyticsProps, serverBrowser, reportProfiles).pipe( + layerWsRpc( + session, + clientAnalyticsProps, + serverBrowser, + reportProfiles, + readSettingsClient(url.value), + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), diff --git a/apps/web/package.json b/apps/web/package.json index 3cb58dec9cd6c..1d79dc07cd2ed 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -54,6 +54,7 @@ "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", + "canvas-confetti": "1.9.4", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "dompurify": "^3.4.16", @@ -97,6 +98,7 @@ "@effect/vitest": "catalog:", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", + "@types/canvas-confetti": "1.9.0", "@types/compression": "^1.8.1", "@types/culori": "^4.0.1", "@types/hast": "^3.0.4", 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/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/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 08e6f7633dee3..903a20066b39e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -160,6 +160,18 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { describe("resolveBranchSelectionTarget", () => { it("reuses an existing secondary worktree for the selected ref", () => { + expect( + resolveBranchSelectionTarget({ + activeProjectCwd: "/repo/nested-checkout/packages/app", + activeProjectCheckoutRoot: "/repo/nested-checkout", + activeWorktreePath: null, + refName: { isDefault: true, worktreePath: "/repo" }, + }), + ).toEqual({ + checkoutCwd: "/repo", + nextWorktreePath: "/repo", + reuseExistingWorktree: true, + }); expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", @@ -177,20 +189,31 @@ describe("resolveBranchSelectionTarget", () => { }); it("switches back to the main repo when the ref already lives there", () => { - expect( - resolveBranchSelectionTarget({ - activeProjectCwd: "/repo", - activeWorktreePath: "/repo/.t3/worktrees/feature-a", - refName: { - isDefault: true, - worktreePath: "/repo", - }, - }), - ).toEqual({ - checkoutCwd: "/repo", - nextWorktreePath: null, - reuseExistingWorktree: true, - }); + // A subdirectory project's own checkout is reported at the repository root. + // The checkout root is undefined until the project's status loads. + for (const [activeProjectCwd, activeWorktreePath, activeProjectCheckoutRoot] of [ + ["/repo", "/repo/.t3/worktrees/feature-a", "/repo"], + ["/repo/packages/app", "/repo/.t3/worktrees/feature-a", "/repo"], + ["/repo/packages/app", null, "/repo"], + ["/repo/packages/app", "/repo/.t3/worktrees/feature-a", undefined], + ["/repo/packages/app", null, undefined], + ] as const) { + expect( + resolveBranchSelectionTarget({ + activeProjectCwd, + activeProjectCheckoutRoot, + activeWorktreePath, + refName: { + isDefault: true, + worktreePath: "/repo", + }, + }), + ).toEqual({ + checkoutCwd: "/repo", + nextWorktreePath: null, + reuseExistingWorktree: true, + }); + } }); it("keeps checkout in the current worktree for non-default refs", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index e22dcfaddedeb..a967c3171fbd1 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -7,6 +7,7 @@ import type { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { sanitizeNewRefName } from "@t3tools/shared/git"; +import { workspaceRelativeFilePath } from "@t3tools/shared/path"; import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort"; export { deriveLocalBranchNameFromRemoteRef, sanitizeNewRefName } from "@t3tools/shared/git"; @@ -247,6 +248,7 @@ export function resolveLocalCheckoutBranchMismatch(input: { export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; + activeProjectCheckoutRoot?: string | undefined; activeWorktreePath: string | null; refName: Pick; }): { @@ -257,9 +259,14 @@ export function resolveBranchSelectionTarget(input: { const { activeProjectCwd, activeWorktreePath, refName } = input; if (refName.worktreePath) { + // Until the project's checkout root loads, a checkout containing the project counts as its own. + const isProjectCheckout = + input.activeProjectCheckoutRoot === undefined + ? workspaceRelativeFilePath(activeProjectCwd, refName.worktreePath) !== null + : workspaceRelativeFilePath(input.activeProjectCheckoutRoot, refName.worktreePath) === "."; return { checkoutCwd: refName.worktreePath, - nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath, + nextWorktreePath: isProjectCheckout ? null : refName.worktreePath, reuseExistingWorktree: true, }; } diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index fab250f6adaf8..9782f258afe19 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -224,6 +224,12 @@ export function BranchToolbarBranchSelector({ input: { cwd: branchCwd }, }), ); + const projectStatusQuery = useEnvironmentQuery( + activeProjectCwd === null + ? null + : vcsEnvironment.status({ environmentId, input: { cwd: activeProjectCwd } }), + ); + const activeProjectCheckoutRoot = projectStatusQuery.data?.workingTree.rootPath; const trimmedBranchQuery = branchQuery.trim(); const branchRefState = usePaginatedBranches({ environmentId, @@ -389,6 +395,7 @@ export function BranchToolbarBranchSelector({ const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, + activeProjectCheckoutRoot, activeWorktreePath, refName, }); @@ -631,6 +638,7 @@ export function BranchToolbarBranchSelector({ (!activeProjectCwd || !resolveBranchSelectionTarget({ activeProjectCwd, + activeProjectCheckoutRoot, activeWorktreePath, refName, }).reuseExistingWorktree)) 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 87354e0d257f9..81b6992a59d3b 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"; @@ -1259,29 +1258,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 93468b9e04abf..4e17fdebf7b52 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1166,7 +1166,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 ); } @@ -1618,28 +1618,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 6fd54db6bd52a..a1c1c7bc91a5b 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"; @@ -93,6 +92,7 @@ import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { codexFeedbackMessage, + deriveWorkspaceMutationId, isCompleteTurnItemRow, shouldShowLoadEarlierControl, type CodexFeedbackSubmission, @@ -111,6 +111,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"; @@ -176,7 +177,6 @@ import { GuestPendingMessages } from "./chat/GuestShareControls"; import { useDeviceState } from "~/state/device"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; import { ComposerContextPermissions } from "./ComposerContextPermissions"; -import { resolveCompatibleRuntimeMode } from "./chat/runtimeModeConfig"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; @@ -1837,9 +1837,6 @@ export default function ChatView(props: ChatViewProps) { }); }; const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus); - const openComposerPermissionsStable = useStableCallback(() => - composerRef.current?.openControl("composer.mode"), - ); const insertArtifactTemplate = (template: CodexArtifactTemplate) => { const composer = composerRef.current; if (!composer) return; @@ -3366,6 +3363,7 @@ export default function ChatView(props: ChatViewProps) { {isWorkspaceFileDragActive ? : null} - + + + {showScrollToBottom && !isThreadFindActive && ( ), })} diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 3883a3e2258e9..9faeab83c6ffc 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -1,13 +1,28 @@ -import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; import { render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { makeThreadFixture } from "../test-fixtures"; +import type { Project, Thread } from "../types"; import { CommandPalette } from "./CommandPalette"; -function setup() { +const entities = vi.hoisted(() => ({ + projects: [] as ReadonlyArray, + threads: [] as ReadonlyArray, +})); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + useProjects: () => entities.projects, + useVisibleThreadShells: () => entities.threads, +})); + +function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArray } = {}) { useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); + entities.projects = state.projects ?? []; + entities.threads = state.threads ?? []; } describe("CommandPalette open intents", () => { @@ -30,4 +45,50 @@ describe("CommandPalette open intents", () => { await expect.element(input).toHaveFocus(); await expect.element(input).toHaveValue("acme"); }); + + it("opens a searched project's latest listed thread, not a newer subagent", async () => { + const environmentId = EnvironmentId.make("local"); + const projectId = ProjectId.make("acme"); + const parentId = ThreadId.make("parent"); + setup({ + projects: [ + { + id: projectId, + environmentId, + title: "Acme", + workspaceRoot: "/work/acme", + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + scripts: [], + }, + ], + threads: [ + makeThreadFixture({ + id: parentId, + environmentId, + projectId, + latestUserMessageAt: "2026-03-02T00:00:00.000Z", + }), + makeThreadFixture({ + id: ThreadId.make("subagent"), + environmentId, + projectId, + updatedAt: "2026-03-03T00:00:00.000Z", + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent", + }, + }), + ], + }); + await using screen = await render({null}); + + openCommandPalette({ query: "Acme" }); + await page.getByRole("option", { name: /Acme/ }).first().click(); + + await vi.waitFor(() => expect(screen.router?.state.location.pathname).toBe("/local/parent")); + }); }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 068808707b4f5..ddac4fc4d7c17 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -212,6 +212,7 @@ import { type SearchOverlayMode, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { selectCommandPaletteClientSettings, @@ -493,7 +494,8 @@ function OpenCommandPaletteDialog(props: { if (activeThreadReferenceCopyTarget === null) return; await copyThreadReference(activeThreadReferenceCopyTarget); }; - const threads = useVisibleThreadShells(); + // Side chats and subagents open inside their parent, so opening a project never lands on one. + const threads = useVisibleThreadShells().filter((thread) => !isSidebarHiddenThread(thread)); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providersByEnvironment = useEnvironmentProviders(); const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems(); diff --git a/apps/web/src/components/ComposerContextPermissions.tsx b/apps/web/src/components/ComposerContextPermissions.tsx index b4ac2a2eb6983..688968f9bca53 100644 --- a/apps/web/src/components/ComposerContextPermissions.tsx +++ b/apps/web/src/components/ComposerContextPermissions.tsx @@ -1,41 +1,67 @@ -import type { RuntimeMode } from "@t3tools/contracts"; +import type { EnvironmentId, RuntimeMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { shortcutLabelForCommand } from "../keybindings"; +import { useManagedPolicy } from "../state/managedPolicy"; import { primaryServerKeybindingsAtom } from "../state/server"; import { ComposerControl } from "./chat/ComposerControl"; -import { runtimeModeConfig } from "./chat/runtimeModeConfig"; +import { ComposerPermissionsSection } from "./chat/ComposerFooterControls"; +import { useComposerMenuProps } from "./chat/composerEventScope"; +import { + resolveCompatibleRuntimeMode, + resolveRuntimeModeOptions, + runtimeModeConfig, +} from "./chat/runtimeModeConfig"; import { ComposerContextLabel } from "./ComposerContextLabel"; +import { Menu, MenuPopup, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -/** The new-thread strip names the permissions the first send will use; clicking opens them. */ export function ComposerContextPermissions(props: { + environmentId: EnvironmentId | null; runtimeMode: RuntimeMode; - onOpen: () => void; + supportedRuntimeModes: ReadonlyArray | undefined; + onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const shortcutLabel = shortcutLabelForCommand( useAtomValue(primaryServerKeybindingsAtom), "composer.mode", ); - const { icon: Icon, label } = runtimeModeConfig[props.runtimeMode]; + const { mode, modes } = resolveCompatibleRuntimeMode( + props.runtimeMode, + props.supportedRuntimeModes, + ); + const options = resolveRuntimeModeOptions(modes, useManagedPolicy(props.environmentId)); + const composerMenuProps = useComposerMenuProps(); + const { icon: Icon, label } = runtimeModeConfig[mode]; return ( - - - } - > - - Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null} - + + + + } + /> + } + > + + Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null} + + + + + ); } diff --git a/apps/web/src/components/GitHubStarPrompt.test.ts b/apps/web/src/components/GitHubStarPrompt.test.ts index 9dbe023b2d56f..f748ec8b82209 100644 --- a/apps/web/src/components/GitHubStarPrompt.test.ts +++ b/apps/web/src/components/GitHubStarPrompt.test.ts @@ -3,29 +3,54 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { toastManager } from "./ui/toast"; -const { add, starred } = vi.hoisted(() => ({ +const { add, update, openExternal, commands, starred, page } = vi.hoisted(() => ({ add: vi.fn(), - starred: { value: false, failed: false, duringCheck: () => {} }, + update: vi.fn(), + openExternal: vi.fn(async (_url: string) => {}), + commands: { checkStarred: {}, star: {} }, + starred: { value: false, failed: false, starWorks: true, duringCheck: () => {} }, + page: { + focused: true, + activeElement: null as { matches: (selector: string) => boolean } | null, + events: new EventTarget(), + }, })); -vi.mock("./ui/toast", () => ({ toastManager: { add, close: vi.fn() } })); +vi.mock("./ui/toast", () => ({ toastManager: { add, update, close: vi.fn() } })); +vi.mock("../localApi", () => ({ readLocalApi: () => ({ shell: { openExternal } }) })); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); -vi.mock("../state/sourceControl", () => ({ sourceControlEnvironment: { checkStarred: {} } })); +vi.mock("../state/sourceControl", () => ({ sourceControlEnvironment: commands })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ - runAtomCommand: async () => { + runAtomCommand: async (_registry: unknown, command: unknown) => { + if (command === commands.star) return { _tag: "Success", value: starred.starWorks }; starred.duringCheck(); return starred.failed ? { _tag: "Failure" } : { _tag: "Success", value: starred.value }; }, })); const environmentId = EnvironmentId.make("environment-1"); +const IDLE_MS = 30_000; function setup({ sent = 0, writable = true, alreadyStarred = false } = {}) { add.mockReset(); + update.mockReset(); + openExternal.mockClear(); starred.value = alreadyStarred; starred.failed = false; + starred.starWorks = true; starred.duringCheck = () => {}; + page.focused = true; + page.activeElement = null; + page.events = new EventTarget(); const store = new Map([["t3code:github-star-prompt:sent", String(sent)]]); + vi.stubGlobal("document", { + hasFocus: () => page.focused, + get activeElement() { + return page.activeElement; + }, + }); vi.stubGlobal("window", { + addEventListener: page.events.addEventListener.bind(page.events), + removeEventListener: page.events.removeEventListener.bind(page.events), localStorage: { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { @@ -41,10 +66,48 @@ function setup({ sent = 0, writable = true, alreadyStarred = false } = {}) { async function startSession() { vi.resetModules(); const session = await import("./GitHubStarPrompt"); - return { send: () => session.recordPromptSent(environmentId) }; + return { + // Sends a prompt, then leaves the page idle long enough to ask. + send: async () => { + const sent = session.recordPromptSent(environmentId); + await vi.advanceTimersByTimeAsync(IDLE_MS); + await sent; + }, + sendWithoutWaiting: () => void session.recordPromptSent(environmentId), + }; } +// The toast's handlers ignore their click event. +const click = (props: unknown) => (props as { onClick: () => unknown }).onClick(); + describe("recordPromptSent", () => { + vi.useFakeTimers(); + + it("asks only after the focused page is idle", async () => { + setup({ sent: 5 }); + (await startSession()).sendWithoutWaiting(); + await vi.advanceTimersByTimeAsync(IDLE_MS - 1); + page.events.dispatchEvent(new Event("keydown")); + await vi.advanceTimersByTimeAsync(IDLE_MS - 1); + expect(add).not.toHaveBeenCalled(); + + page.focused = false; + await vi.advanceTimersByTimeAsync(IDLE_MS); + expect(add).not.toHaveBeenCalled(); + + page.focused = true; + page.activeElement = { matches: (selector) => selector.includes("iframe") }; + page.events.dispatchEvent(new Event("focus")); + await vi.advanceTimersByTimeAsync(IDLE_MS); + expect(add).not.toHaveBeenCalled(); + + page.activeElement = null; + page.events.dispatchEvent(new Event("pointerdown")); + await vi.advanceTimersByTimeAsync(IDLE_MS); + expect(add).toHaveBeenCalledOnce(); + vi.unstubAllGlobals(); + }); + it("asks after five prompts, once per session, and never again once closed", async () => { setup(); let session = await startSession(); @@ -101,4 +164,36 @@ describe("recordPromptSent", () => { expect(add).not.toHaveBeenCalled(); vi.unstubAllGlobals(); }); + it("stars on click, opens GitHub when starring fails, and asks for a click once the click expired", async () => { + const store = setup({ sent: 5 }); + await (await startSession()).send(); + click(add.mock.lastCall?.[0].actionProps); + await vi.advanceTimersByTimeAsync(0); + expect(update.mock.lastCall?.[1]).toMatchObject({ title: "Thanks for the star!" }); + expect(store.get("t3code:github-star-prompt:dismissed")).toBe("true"); + expect(openExternal).not.toHaveBeenCalled(); + + setup({ sent: 5 }); + starred.starWorks = false; + await (await startSession()).send(); + click(add.mock.lastCall?.[0].actionProps); + await vi.advanceTimersByTimeAsync(0); + expect(openExternal).toHaveBeenCalledWith("https://github.com/pingdotgg/t3code"); + expect(update.mock.lastCall?.[1]).not.toMatchObject({ + actionProps: { children: "Open GitHub" }, + }); + + setup({ sent: 5 }); + starred.starWorks = false; + vi.stubGlobal("navigator", { userActivation: { isActive: false } }); + await (await startSession()).send(); + click(add.mock.lastCall?.[0].actionProps); + await vi.advanceTimersByTimeAsync(0); + const fallback = update.mock.lastCall?.[1]; + expect(fallback).toMatchObject({ actionProps: { children: "Open GitHub" } }); + expect(openExternal).not.toHaveBeenCalled(); + click(typeof fallback === "object" ? fallback.actionProps : undefined); + expect(openExternal).toHaveBeenCalledOnce(); + vi.unstubAllGlobals(); + }); }); diff --git a/apps/web/src/components/GitHubStarPrompt.tsx b/apps/web/src/components/GitHubStarPrompt.tsx index db8ddb4de2035..679d61b1f3502 100644 --- a/apps/web/src/components/GitHubStarPrompt.tsx +++ b/apps/web/src/components/GitHubStarPrompt.tsx @@ -14,6 +14,9 @@ const SENT_KEY = "t3code:github-star-prompt:sent"; const DISMISSED_KEY = "t3code:github-star-prompt:dismissed"; const REPOSITORY = "pingdotgg/t3code"; const PROMPTS_BEFORE_ASKING = 5; +const IDLE_MS = 30_000; +// User-driven actions (send, navigate, type, scroll) start with one of these; touch fires pointerdown. +const ACTIVITY_EVENTS = ["pointerdown", "keydown", "wheel", "focus"] as const; let shownThisSession = false; @@ -24,22 +27,41 @@ function dismiss() { } // Every close path, including both buttons, runs `onClose`, which saves the dismissal. -function showStarPrompt() { +function showStarPrompt(environmentId: EnvironmentId) { const close = () => toastManager.close(id); + const openInBrowser = () => { + close(); + void readLocalApi() + ?.shell.openExternal(`https://github.com/${REPOSITORY}`) + .catch(() => undefined); + }; + const starInPlace = async () => { + toastManager.update(id, { actionProps: { children: "Starring…", disabled: true } }); + if (await starRepository(environmentId)) { + // Thank them in place: a closing and opening toast would slide under the confetti. + dismiss(); + toastManager.update(id, { + type: "success", + title: "Thanks for the star!", + description: "It really helps T3 Code grow.", + timeout: 5_000, + actionProps: undefined, + data: {}, + }); + celebrate(); + return; + } + // Browsers block new tabs once the click's activation expires; desktop opens through its bridge. + if (window.desktopBridge || navigator.userActivation?.isActive !== false) + return openInBrowser(); + toastManager.update(id, { actionProps: { children: "Open GitHub", onClick: openInBrowser } }); + }; const id = toastManager.add({ title: "Enjoying T3 Code?", description: "Give us a star on GitHub to let us know!", timeout: 0, onClose: dismiss, - actionProps: { - children: "Star on GitHub", - onClick: () => { - close(); - void readLocalApi() - ?.shell.openExternal(`https://github.com/${REPOSITORY}`) - .catch(() => undefined); - }, - }, + actionProps: { children: "Star on GitHub", onClick: () => void starInPlace() }, data: { actionLayout: "stacked-end", leadingIcon: , @@ -49,27 +71,76 @@ function showStarPrompt() { }); } +// One burst out of the toast. The library loads only here, and its canvas stops when done. +function celebrate() { + void import("canvas-confetti") + .then(({ default: confetti }) => + confetti({ + particleCount: 160, + spread: 360, + startVelocity: 38, + gravity: 1.6, + ticks: 140, + origin: { x: 0.83, y: 0.15 }, + disableForReducedMotion: true, + }), + ) + .catch(() => undefined); +} + +// Resolves after IDLE_MS without input while this window has focus. +function waitForIdle() { + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const restart = () => { + clearTimeout(timer); + timer = setTimeout(() => { + // Input inside another app or an embedded frame never reaches us; the next focus restarts. + if (!document.hasFocus() || document.activeElement?.matches("iframe, webview")) return; + for (const type of ACTIVITY_EVENTS) window.removeEventListener(type, restart, true); + resolve(); + }, IDLE_MS); + }; + for (const type of ACTIVITY_EVENTS) { + window.addEventListener(type, restart, { capture: true, passive: true }); + } + restart(); + }); +} + // The server's GitHub account stands in for the user's; it may not be theirs on a shared server. +const starInput = (environmentId: EnvironmentId) => ({ + environmentId, + input: { + provider: SourceControlProviderKind.make("github"), + host: "github.com", + repository: REPOSITORY, + }, +}); + async function alreadyStarred(environmentId: EnvironmentId) { const result = await runAtomCommand( appAtomRegistry, sourceControlEnvironment.checkStarred, - { - environmentId, - input: { - provider: SourceControlProviderKind.make("github"), - host: "github.com", - repository: REPOSITORY, - }, - }, + starInput(environmentId), { label: "check GitHub star", reportFailure: false, reportDefect: false }, ); return result._tag === "Success" ? result.value : undefined; } +async function starRepository(environmentId: EnvironmentId) { + const result = await runAtomCommand( + appAtomRegistry, + sourceControlEnvironment.star, + starInput(environmentId), + { label: "star on GitHub", reportFailure: false, reportDefect: false }, + ); + return result._tag === "Success" && result.value; +} + /** * Counts a sent prompt. After more than five, asks once per session for a star until dismissed, - * unless the environment's GitHub account already starred the repo. + * unless the environment's GitHub account already starred the repo. Waits until the user is idle. */ export async function recordPromptSent(environmentId: EnvironmentId): Promise { if (shownThisSession) return; @@ -86,11 +157,12 @@ export async function recordPromptSent(environmentId: EnvironmentId): Promise ({ + openCommandPalette: vi.fn(), + isCommandPaletteOpen: () => false, +})); +vi.mock("./sidebar/SidebarChrome", () => ({ + SidebarChromeHeader: () => null, + SidebarChromeFooter: () => null, +})); + +function project(id: string): Project { + return { + id: ProjectId.make(id), + environmentId: EnvironmentId.make("local"), + title: id, + workspaceRoot: `/projects/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + }; +} + +describe("sidebar header new thread", () => { + it.each([ + { mode: "normal", count: 2, shift: false, picker: true }, + { mode: "boring", count: 2, shift: false, picker: true }, + { mode: "boring", count: 2, shift: true, picker: false }, + { mode: "boring", count: 1, shift: false, picker: false }, + { mode: "legacy", count: 2, shift: false, picker: false }, + ] as const)("$mode sidebar, $count projects, shift=$shift: picker=$picker", async (scenario) => { + using stack = new DisposableStack(); + const previousSettings = getClientSettings(); + stack.defer(() => __setClientSettingsForTests(previousSettings)); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + boringSidebarEnabled: scenario.mode === "boring", + legacySidebarEnabled: scenario.mode === "legacy", + }); + const projects = [project("active"), project("idle")].slice(0, scenario.count); + const currentProjectRef = { + environmentId: projects[0]!.environmentId, + projectId: projects[0]!.id, + }; + const startThread = vi.fn().mockResolvedValue(null); + vi.mocked(useProjects).mockReturnValue(projects); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: currentProjectRef, + handleNewThread: startThread, + routeDraftId: null, + routeThreadRef: null, + }); + vi.mocked(openCommandPalette).mockClear(); + await using screen = await render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "New thread", exact: true }), { + modifiers: scenario.shift ? ["Shift"] : [], + }); + + if (scenario.picker) { + expect(openCommandPalette).toHaveBeenCalledExactlyOnceWith({ open: "new-thread-in" }); + expect(startThread).not.toHaveBeenCalled(); + } else { + expect(openCommandPalette).not.toHaveBeenCalled(); + expect(startThread).toHaveBeenCalledExactlyOnceWith(currentProjectRef); + } + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 669c805492bcc..3e2e8be2ce18d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -624,10 +624,45 @@ describe("getFallbackThreadIdAfterDelete", () => { deletedThreadId: ThreadId.make("thread-active"), deletedThreadIds: new Set([ThreadId.make("thread-active"), ThreadId.make("thread-newest")]), sortOrder: "created_at", + now: "2026-03-09T10:10:00.000Z", }); expect(fallbackThreadId).toBe(ThreadId.make("thread-next")); }); + + it.each([ + [ + "snoozed", + { snoozedUntil: "2099-01-01T00:00:00.000Z", snoozedAt: "2026-03-09T10:06:00.000Z" }, + ], + ["settled", { settledOverride: "settled", settledAt: "2026-03-09T10:06:00.000Z" }], + ] as const)("skips a %s thread parked on its shelf", (_state, parked) => { + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads: [ + makeThread({ + id: ThreadId.make("thread-visible"), + createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-parked"), + createdAt: "2026-03-09T10:02:00.000Z", + updatedAt: "2026-03-09T10:06:00.000Z", + ...parked, + }), + makeThread({ + id: ThreadId.make("thread-active"), + createdAt: "2026-03-09T10:03:00.000Z", + updatedAt: "2026-03-09T10:03:00.000Z", + }), + ], + deletedThreadId: ThreadId.make("thread-active"), + sortOrder: "created_at", + now: "2026-03-09T10:10:00.000Z", + }); + + expect(fallbackThreadId).toBe(ThreadId.make("thread-visible")); + }); }); describe("sortScopedProjectsForSidebar", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 55cf694c2b79e..c20789e2a9160 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1211,14 +1211,17 @@ export function resolveThreadStatusPill(input: { } export function getFallbackThreadIdAfterDelete< - T extends Pick & ThreadSortInput, + T extends Pick & + ThreadSortInput & + ThreadSnoozeShell, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; sortOrder: SidebarThreadSortOrder; + now: string; deletedThreadIds?: ReadonlySet; }): T["id"] | null { - const { deletedThreadId, deletedThreadIds, sortOrder, threads } = input; + const { deletedThreadId, deletedThreadIds, sortOrder, now, threads } = input; const deletedThread = threads.find((thread) => thread.id === deletedThreadId); if (!deletedThread) { return null; @@ -1230,7 +1233,9 @@ export function getFallbackThreadIdAfterDelete< (thread) => thread.projectId === deletedThread.projectId && thread.id !== deletedThreadId && - !deletedThreadIds?.has(thread.id), + !deletedThreadIds?.has(thread.id) && + thread.settledOverride !== "settled" && + !effectiveSnoozed(thread, { now }), ), sortOrder, )[0]?.id ?? null diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index deede26763fdd..868aa85cec61b 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"; @@ -1006,6 +1006,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, }); const boringSidebarEnabled = useBoringSidebarEnabled(); + const legacySidebarEnabled = useClientSettings((settings) => settings.legacySidebarEnabled); const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. @@ -1031,7 +1032,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const projectScopeFilter = useComboboxFilter(); const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom); - const headerSearchRef = useRef(null); // Safari may click after Ctrl+click opens settings; suppress that selection until the picker reopens. const suppressNextScopeChangeRef = useRef(false); const highlightedProjectScopeKeyRef = useRef(null); @@ -2950,7 +2950,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( - boringSidebarEnabled || + legacySidebarEnabled || shouldCreateNewThreadInCurrentProject( event?.shiftKey ?? false, scopeGroups.length, @@ -2989,7 +2989,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve fixedHeader={ 0} projectScope={ closeType === "keyboard"} > {item.label} - {project && showProjectEnvironments ? ( - - ) : null} {project && canPinProject(project) ? ( toggleProjectGroupPin(project)} /> ) : null} + {project && showProjectEnvironments ? ( + + ) : null} {project ? ( + + + // 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 5585a062dd652..fca8b88925e96 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"; @@ -127,6 +129,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)); @@ -830,6 +851,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, @@ -866,10 +891,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/SecretSourceInput.browser.test.tsx b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx index e1b67465c2168..f7c598ab10568 100644 --- a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx @@ -23,6 +23,7 @@ async function commitReference(screen: Screen, value: string) { describe("SecretSourceInput", () => { it("moves a reference to another provider only once one is committed for it", async () => { const onSecretChange = vi.fn(); + const onValidityChange = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} + onValidityChange={onValidityChange} />, ); await userEvent.click(screen.getByRole("combobox", { name: "API key source" })); await userEvent.click(screen.getByRole("option", { name: "Work" })); expect(onSecretChange).not.toHaveBeenCalled(); + expect(onValidityChange).toHaveBeenLastCalledWith(false); await expect.element(reference(screen)).toHaveValue(""); await commitReference(screen, "op://Shared/OpenAI/credential"); @@ -43,6 +46,7 @@ describe("SecretSourceInput", () => { provider: work, reference: "op://Shared/OpenAI/credential", }); + expect(onValidityChange).toHaveBeenLastCalledWith(true); }); it("removes the reference when its field is cleared", async () => { @@ -57,6 +61,8 @@ describe("SecretSourceInput", () => { />, ); + await commitReference(screen, "invalid-reference"); + await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await userEvent.clear(reference(screen)); await userEvent.keyboard("{Enter}"); expect(onSecretChange).toHaveBeenCalledExactlyOnceWith(undefined); @@ -64,6 +70,7 @@ describe("SecretSourceInput", () => { it("rejects a reference 1Password cannot read", async () => { const onSecretChange = vi.fn(); + const onValidityChange = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} + onValidityChange={onValidityChange} />, ); await commitReference(screen, "Private/OpenAI/credential"); expect(onSecretChange).not.toHaveBeenCalled(); + expect(onValidityChange).toHaveBeenLastCalledWith(false); await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential"); + await commitReference(screen, "op://Private/OpenAI/replacement"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + expect(onSecretChange).toHaveBeenCalledExactlyOnceWith({ + provider: personal, + reference: "op://Private/OpenAI/replacement", + }); }); }); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 2fed001b09e91..67053a054db38 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -64,6 +64,8 @@ type SecretSourceInputProps = Omit, "value" | readonly value: string | SecretReference; readonly sources: ReadonlyArray; readonly onSecretChange: (secret: SecretReference | undefined) => void; + /** Whether the selected source and committed field can be saved or used. */ + readonly onValidityChange?: (valid: boolean) => void; /** Places the parts in the caller's layout; `source` is null when there is nothing to pick. */ readonly children?: (parts: { readonly source: ReactNode; @@ -76,6 +78,7 @@ export function SecretSourceInput({ value, sources, onSecretChange, + onValidityChange, children, ...rest }: SecretSourceInputProps) { @@ -102,10 +105,12 @@ export function SecretSourceInput({ setRejectedReference(null); if (value === STORED_VALUE || value === null) { setPendingProvider(null); + onValidityChange?.(true); if (secret !== undefined) onSecretChange(undefined); return; } const next = value as SecretProviderId; + onValidityChange?.(next === secret?.provider); setPendingProvider(next === secret?.provider ? null : next); }} > @@ -150,6 +155,7 @@ export function SecretSourceInput({ onCommit={(next) => { if (next.trim().length === 0) { setRejectedReference(null); + onValidityChange?.(pendingProvider === null && secret !== undefined); if (pendingProvider === null && secret !== undefined) onSecretChange(undefined); return; } @@ -159,10 +165,12 @@ export function SecretSourceInput({ definition?.isReference(decoded.value.reference) === false ) { setRejectedReference(next); + onValidityChange?.(false); return; } setRejectedReference(null); setPendingProvider(null); + onValidityChange?.(true); onSecretChange(decoded.value); }} autoComplete="off" diff --git a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx index df73fa3b24b45..54b0018a249c7 100644 --- a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx +++ b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx @@ -171,7 +171,7 @@ export function SourceControlAppSignInSettings({ /> ) : tokenOverrides ? ( - "Signed in, but the token saved below is used until you remove it." + "Signed in, but the token saved below is used while it can be read." ) : ( "Signed in" ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index d28e467b0cf70..e61a064431c55 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -604,7 +604,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", }, { @@ -806,7 +808,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/settings/useHostConnectionChecks.browser.test.tsx b/apps/web/src/components/settings/useHostConnectionChecks.browser.test.tsx new file mode 100644 index 0000000000000..93c2776034a4d --- /dev/null +++ b/apps/web/src/components/settings/useHostConnectionChecks.browser.test.tsx @@ -0,0 +1,64 @@ +import { DeviceHostUnavailableError, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; + +const state = vi.hoisted(() => ({ test: vi.fn() })); +function setup() { + state.test.mockReset(); +} + +vi.mock(import("../../state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: (() => state.test) as never, +})); +vi.mock(import("../../state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: (() => true) as never, +})); + +import { useHostConnectionChecks } from "./useHostConnectionChecks"; + +const environmentId = EnvironmentId.make("environment-1"); +const targets = [{ environmentId, label: "This Mac", connected: true }]; + +function Harness({ + onReady, +}: { + onReady: (run: ReturnType) => void; +}) { + onReady(useHostConnectionChecks(targets)); + return null; +} + +describe("useHostConnectionChecks", () => { + it("reports a failed check by its message, without a stack trace", async () => { + setup(); + state.test.mockResolvedValue( + AsyncResult.failure( + Cause.fail( + new DeviceHostUnavailableError({ + hostId: "mac", + hostLabel: "Mac mini", + reason: "SSH connection refused.", + }), + ), + ), + ); + let checks!: ReturnType; + await using _screen = await render( (checks = next)} />, { + router: false, // the hook reads no route state + }); + + const results = await act(() => + checks.testConnection({ kind: "ssh", id: "mac", label: "Mac mini", target: "user@mac" }), + ); + + expect(results?.[environmentId]).toEqual({ + status: "failed", + error: "Mac mini is unavailable: SSH connection refused.", + }); + }); +}); diff --git a/apps/web/src/components/settings/useHostConnectionChecks.ts b/apps/web/src/components/settings/useHostConnectionChecks.ts index 4b2733cdfcf42..e96b992a149b8 100644 --- a/apps/web/src/components/settings/useHostConnectionChecks.ts +++ b/apps/web/src/components/settings/useHostConnectionChecks.ts @@ -1,7 +1,7 @@ import { AuthSettingsWriteScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "../../state/session"; import { useRef, useState } from "react"; -import * as Cause from "effect/Cause"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { SshDeviceHostConfig } from "@t3tools/contracts"; import { deviceEnvironment } from "../../state/device"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -30,7 +30,7 @@ export function useHostConnectionChecks(targets: ReadonlyArray { 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 (
  • + + + ); +} + +// The tracker records on onResolved, which the router emits only once React commits the page. +async function settled(router: AnyRouter, navigation: Promise) { + await navigation; + await vi.waitFor(() => + expect(router.state.resolvedLocation?.href).toBe(router.state.location.href), + ); +} + +describe("main app return location", () => { + it("returns to the thread without re-opening the quote source it was opened at", async () => { + await using screen = await render(, { router: THREAD_PATH }); + const router = screen.router!; + using _tracking = { [Symbol.dispose]: startMainAppLocationTracking(router) }; + + const returnLocation = async (from: string, control: string) => { + await settled(router, router.navigate(assistantCitationNavigation(citation))); + expect(assistantCitationFromLocation(router.state.location.href)).toEqual(citation); + await settled(router, router.navigate({ href: from })); + await userEvent.click(screen.getByRole("button", { name: control })); + await vi.waitFor(() => expect(router.state.location.pathname).toBe(THREAD_PATH)); + return router.state.location.href; + }; + + const closingUsage = await returnLocation("/usage", "Close modal"); + const sidebarBack = await returnLocation("/pull-requests", "Back to app"); + + expect({ closingUsage, sidebarBack }).toEqual({ + closingUsage: THREAD_PATH, + sidebarBack: THREAD_PATH, + }); + }); +}); diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index a732d9f435bfc..8e4267af73890 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,6 +1,7 @@ import { useNavigate, type AnyRouter } from "@tanstack/react-router"; import { GUEST_PAGE_PATH } from "../../guestPage"; +import { withoutAssistantCitationHash } from "../../lib/assistantCitationNavigation"; /** Settings and Usage open as modals over the last main-app view. */ export function isAppModalPage(pathname: string) { @@ -22,8 +23,9 @@ let appModalReturnHref: string | null = null; /** Remembers where Settings, Usage, and the utility pages lead back to. */ export function startMainAppLocationTracking(router: AnyRouter): () => void { const record = () => { - const { pathname, href } = router.state.location; + const { pathname } = router.state.location; if (isAppModalPage(pathname) || SHELL_LESS_PATHS.has(pathname)) return; + const href = withoutAssistantCitationHash(router.state.location.href); appModalReturnHref = href; if (!isSidebarUtilityPage(pathname)) mainAppHref = href; }; diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index a1db1108fa17a..739eb15b9a064 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -139,6 +139,7 @@ function ComboboxPopup({ sideOffset = 4, alignOffset, align = "start", + collisionAvoidance, anchor: anchorProp, ...props }: ComboboxPrimitive.Popup.Props & { @@ -147,6 +148,7 @@ function ComboboxPopup({ alignOffset?: ComboboxPrimitive.Positioner.Props["alignOffset"]; side?: ComboboxPrimitive.Positioner.Props["side"]; anchor?: ComboboxPrimitive.Positioner.Props["anchor"]; + collisionAvoidance?: ComboboxPrimitive.Positioner.Props["collisionAvoidance"]; }) { const { chipsRef, multiple } = React.use(ComboboxContext); const anchor = anchorProp ?? (multiple ? chipsRef : undefined); @@ -157,6 +159,7 @@ function ComboboxPopup({ align={align} alignOffset={alignOffset} anchor={anchor} + collisionAvoidance={collisionAvoidance} className="z-[130] select-none" data-slot="combobox-positioner" side={side} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e5eedffc16698..904b94ebfc5c5 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -554,6 +554,7 @@ export function useThreadActions() { deletedThreadId: threadRef.threadId, deletedThreadIds, sortOrder: sidebarThreadSortOrder, + now: new Date().toISOString(), }); const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, diff --git a/apps/web/src/lib/assistantCitationNavigation.ts b/apps/web/src/lib/assistantCitationNavigation.ts index 1320b73aa33c5..3300d086d08c1 100644 --- a/apps/web/src/lib/assistantCitationNavigation.ts +++ b/apps/web/src/lib/assistantCitationNavigation.ts @@ -39,6 +39,13 @@ export function assistantCitationFromLocation(href: string) { } } +/** Return paths drop the quote hash so going back does not reveal its source again. */ +export function withoutAssistantCitationHash(href: string) { + const hashIndex = href.indexOf("#"); + if (hashIndex === -1 || !href.startsWith(CITATION_HASH_PREFIX, hashIndex + 1)) return href; + return href.slice(0, hashIndex); +} + /** A fresh activation lets the same link reveal its source again after dismissal. */ export function assistantCitationNavigation(citation: AssistantCitation) { return { diff --git a/apps/web/src/lib/sidebarTree.browser.test.ts b/apps/web/src/lib/sidebarTree.browser.test.ts index 6b02cd3b842bf..c6bb67bd2cb99 100644 --- a/apps/web/src/lib/sidebarTree.browser.test.ts +++ b/apps/web/src/lib/sidebarTree.browser.test.ts @@ -4,14 +4,12 @@ import { installSidebarTree } from "./sidebarTree"; function setup() { const root = document.createElement("div"); - root.setAttribute("role", "tree"); document.body.append(root); let dispose: (() => void) | undefined; const row = (id: string, parent?: string) => { const element = document.createElement("button"); element.dataset.sidebarRow = id; if (parent) element.dataset.sidebarParent = parent; - element.setAttribute("role", "treeitem"); element.textContent = id; root.append(element); return element; @@ -36,7 +34,7 @@ function press(target: HTMLElement, key: string, init: KeyboardEventInit = {}) { } describe("sidebar keyboard navigation", () => { - it("moves focus without activating rows, clamps at the ends, and keeps one Tab stop", () => { + it("moves focus without activating rows, clamps at the ends, and keeps every row in the Tab order", () => { using tree = setup(); const first = tree.row("first"); const second = tree.row("second"); @@ -54,10 +52,10 @@ describe("sidebar keyboard navigation", () => { press(last, "Home"); expect(document.activeElement).toBe(first); expect(activate).not.toHaveBeenCalled(); - expect(tree.root.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + expect([first.tabIndex, second.tabIndex, last.tabIndex]).toEqual([0, 0, 0]); }); - it("expands a group before entering its first child and returns to the parent", async () => { + it("expands a group before entering its first child and returns to the parent", () => { using tree = setup(); const group = tree.row("project"); group.setAttribute("aria-expanded", "false"); @@ -72,12 +70,6 @@ describe("sidebar keyboard navigation", () => { press(group, "ArrowRight"); expect(group.getAttribute("aria-expanded")).toBe("true"); expect(document.activeElement).toBe(group); - // The tree picks up the new child from its mutation observer. - await vi.waitFor(() => - expect( - tree.root.querySelector('[data-sidebar-parent="project"]')?.getAttribute("aria-level"), - ).toBe("2"), - ); press(group, "ArrowRight"); const child = document.activeElement as HTMLElement; expect(child.dataset.sidebarRow).toBe("thread"); @@ -104,7 +96,7 @@ describe("sidebar keyboard navigation", () => { expect(document.activeElement).toBe(selected); }); - it("lets Tab reach only the focused row's buttons", () => { + it("leaves every row's buttons in the Tab order", () => { using tree = setup(); const first = tree.row("first"); const second = tree.row("second"); @@ -119,10 +111,10 @@ describe("sidebar keyboard navigation", () => { const secondAction = action("second"); tree.install(); first.focus(); - expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, -1]); + expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]); press(first, "ArrowDown"); expect(document.activeElement).toBe(second); - expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]); + expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]); }); it("keeps a rendered row under a collapsed group reachable", () => { @@ -152,26 +144,6 @@ describe("sidebar keyboard navigation", () => { expect(undo.tabIndex).toBe(0); }); - it("gives row buttons back their own tab order when the tree reinstalls", () => { - using tree = setup(); - tree.row("first"); - const second = tree.row("second"); - const action = (owner: string) => { - const button = document.createElement("button"); - button.dataset.sidebarActionFor = owner; - tree.root.append(button); - return button; - }; - const firstAction = action("first"); - const secondAction = action("second"); - const dispose = installSidebarTree(tree.root); - second.focus(); - dispose(); - expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]); - tree.install(); - expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]); - }); - it("keeps the scroll position when focus follows a row that moved away", async () => { using tree = setup(); Object.assign(tree.root.style, { height: "100px", overflow: "auto" }); @@ -191,7 +163,7 @@ describe("sidebar keyboard navigation", () => { expect(tree.root.scrollTop).toBe(0); }); - it("does not pull focus back after a click outside the tree", async () => { + it("does not pull focus back after a click outside the rows", async () => { using tree = setup(); const first = tree.row("first"); tree.install(); @@ -199,16 +171,18 @@ describe("sidebar keyboard navigation", () => { text.textContent = "Chat text"; document.body.append(text); using _text = { [Symbol.dispose]: () => text.remove() }; - first.focus(); - await userEvent.click(text); - expect(document.activeElement).toBe(document.body); - // A status change re-renders the tree while the user works elsewhere. - tree.row("second"); - await vi.waitFor(() => - expect( - tree.root.querySelector('[data-sidebar-row="second"]')?.getAttribute("aria-posinset"), - ).toBe("2"), - ); - expect(document.activeElement).toBe(document.body); + const spacer = document.createElement("div"); + spacer.textContent = "Empty list space"; + tree.root.append(spacer); + for (const target of [text, spacer]) { + first.focus(); + await userEvent.click(target); + expect(document.activeElement).toBe(document.body); + // A status change re-renders the list while the user works elsewhere. + tree.row(`second-${target.tagName}`); + // The list's mutation observer callback is queued ahead of this continuation. + await Promise.resolve(); + expect(document.activeElement).toBe(document.body); + } }); }); diff --git a/apps/web/src/lib/sidebarTree.ts b/apps/web/src/lib/sidebarTree.ts index 07859c72bd901..7a6183b5e4acb 100644 --- a/apps/web/src/lib/sidebarTree.ts +++ b/apps/web/src/lib/sidebarTree.ts @@ -1,139 +1,82 @@ const ROW = "[data-sidebar-row]"; -const CONTROL = 'button, a[href], input, textarea, select, [contenteditable="true"], [tabindex]'; -const TEXT_ENTRY = 'input, textarea, select, [contenteditable="true"]'; -function setAttribute(element: Element, name: string, value: string) { - if (element.getAttribute(name) !== value) element.setAttribute(name, value); -} - -function setTabIndex(element: HTMLElement, value: number) { - if (element.tabIndex !== value) element.tabIndex = value; -} - -export const SIDEBAR_TREE_PROPS = { role: "tree", "aria-label": "Threads" } as const; - -/** Ref callback for a sidebar list; installs the tree and returns its cleanup. */ +/** Ref callback for a sidebar list; installs its keyboard navigation and returns its cleanup. */ export function attachSidebarTree(element: HTMLElement | null) { return element ? installSidebarTree(element) : undefined; } -/** A roving DOM focus, separate from thread activation, as in VS Code's list commands. */ +/** + * Arrow keys move focus between rows without opening them, as in VS Code's list commands. + * Rows stay ordinary buttons in the Tab order so screen readers can browse the list. + */ export function installSidebarTree(root: HTMLElement) { - let rows: HTMLElement[] = []; let focusedId: string | undefined; + let focusedParent: string | undefined; + let focusedIndex = -1; let ownsFocus = false; - let tabStop: HTMLElement | undefined; let contextMenuRow: HTMLElement | undefined; - // A control's own tab index, restored while its row holds the Tab stop and on uninstall. - const ownTabIndex = new Map(); - const rowControls = (row: HTMLElement) => { - const id = row.dataset.sidebarRow; - const linked = id - ? root.querySelectorAll(`[data-sidebar-action-for="${CSS.escape(id)}"]`) - : []; - return [...row.querySelectorAll(CONTROL), ...linked].filter( - (control) => !control.matches(ROW) && !control.matches(TEXT_ENTRY), - ); - }; - // Tab reaches the buttons of the focused row only, so the tree stays one stop for the rest. - const setRowControlsReachable = (row: HTMLElement, reachable: boolean) => { - for (const control of rowControls(row)) { - if (!ownTabIndex.has(control)) ownTabIndex.set(control, control.tabIndex); - setTabIndex(control, reachable ? ownTabIndex.get(control)! : -1); - } + const isVisibleRow = (row: HTMLElement) => { + const hidden = row.closest('[hidden], [inert], [aria-hidden="true"]'); + // A dialog can temporarily make the whole workspace inert. Keep rows navigable + // for its return while still excluding exiting row clones. + return (!hidden || !root.contains(hidden)) && !row.matches(":disabled"); }; + const visibleRows = () => + Array.from(root.querySelectorAll(ROW)).filter(isVisibleRow); - const setTabStop = (row: HTMLElement) => { - if (tabStop === row) return; - if (tabStop) { - setTabIndex(tabStop, -1); - setRowControlsReachable(tabStop, false); - } - setTabIndex(row, 0); - setRowControlsReachable(row, true); - tabStop = row; - focusedId = row.dataset.sidebarRow; + const ownerRow = (element: HTMLElement) => { + const row = element.closest(ROW); + if (row) return row; + const linked = element.dataset.sidebarActionFor; + return linked + ? root.querySelector(`[data-sidebar-row="${CSS.escape(linked)}"]`) + : null; }; const focusRow = (row: HTMLElement) => { - setTabStop(row); // Revealing before focusing also materializes content-visibility:auto descendants. row.scrollIntoView({ block: "nearest" }); row.focus({ preventScroll: true }); }; - const refresh = () => { - const previousRows = rows; - const previousIndex = previousRows.findIndex((row) => row.dataset.sidebarRow === focusedId); - const previous = previousRows[previousIndex]; - rows = Array.from(root.querySelectorAll(ROW)).filter((row) => { - const hidden = row.closest('[hidden], [inert], [aria-hidden="true"]'); - // A dialog can temporarily make the whole workspace inert. Keep the tree's - // Tab stop ready for its return while still excluding exiting row clones. - return (!hidden || !root.contains(hidden)) && !row.matches(":disabled"); - }); - let selected = + // A row that moves shelves remounts and drops focus to the body; follow it. + const restoreFocus = () => { + if (!ownsFocus || focusedId === undefined) return; + const active = document.activeElement; + const lost = + active === document.body || + (active instanceof HTMLElement && + root.contains(active) && + active.matches(ROW) && + !isVisibleRow(active)); + if (!lost) return; + const rows = visibleRows(); + const target = rows.find((row) => row.dataset.sidebarRow === focusedId) ?? - rows.find((row) => row === document.activeElement); - if (!selected) { - selected = - rows.find((row) => row.dataset.sidebarRow === previous?.dataset.sidebarParent) ?? - (previousIndex === -1 ? rows.find((row) => row.hasAttribute("aria-current")) : undefined) ?? - rows[Math.min(Math.max(previousIndex, 0), rows.length - 1)]; - } - focusedId = selected?.dataset.sidebarRow; - tabStop = selected; - setTabIndex(root, selected ? -1 : 0); - const siblings = new Map(); - for (const row of rows) { - const parent = row.dataset.sidebarParent ?? ""; - const group = siblings.get(parent) ?? []; - group.push(row); - siblings.set(parent, group); - setAttribute(row, "aria-level", parent ? "2" : "1"); - setTabIndex(row, row === selected ? 0 : -1); - } - const byId = new Map(rows.map((row) => [row.dataset.sidebarRow, row])); - for (const control of ownTabIndex.keys()) if (!control.isConnected) ownTabIndex.delete(control); - for (const control of root.querySelectorAll(CONTROL)) { - if (control.matches(ROW) || control.matches(TEXT_ENTRY)) continue; - const linked = control.dataset.sidebarActionFor; - const owner = control.closest(ROW) ?? (linked ? byId.get(linked) : undefined); - if (!owner) continue; - if (!ownTabIndex.has(control)) ownTabIndex.set(control, control.tabIndex); - setTabIndex(control, owner === selected ? ownTabIndex.get(control)! : -1); - } - for (const group of siblings.values()) - group.forEach((row, index) => { - setAttribute(row, "aria-posinset", String(index + 1)); - setAttribute(row, "aria-setsize", String(group.length)); - }); - for (const listItem of root.querySelectorAll("li:not([role])")) - listItem.setAttribute("role", "presentation"); - if ( - ownsFocus && - selected && - (document.activeElement === document.body || - document.activeElement === root || - (root.contains(document.activeElement) && - !rows.includes(document.activeElement as HTMLElement) && - document.activeElement?.matches(ROW))) - ) { - // A settled row moves to the bottom shelf; scrolling after it fights the route reveal. - selected.focus({ preventScroll: true }); - } - if (ownsFocus && !selected && document.activeElement === document.body) root.focus(); + rows.find((row) => row.dataset.sidebarRow === focusedParent) ?? + rows[Math.min(Math.max(focusedIndex, 0), rows.length - 1)]; + // A settled row moves to the bottom shelf; scrolling after it fights the route reveal. + target?.focus({ preventScroll: true }); }; - // Clicking non-focusable content drops focus to the body without a focusin event. + // Clicking non-focusable content, including empty list space, drops focus to the body + // without a focusin event. A click on the focused row fires no focusin either. const onPointerDown = (event: PointerEvent) => { - if (!root.contains(event.target as Node)) ownsFocus = false; + const focusable = + event.target instanceof Element + ? event.target.closest(`${ROW}, button, a[href], input, [tabindex]`) + : null; + ownsFocus = focusable !== null && root.contains(focusable); }; const onFocus = (event: FocusEvent) => { ownsFocus = root.contains(event.target as Node); - if (!ownsFocus || !(event.target instanceof HTMLElement) || !event.target.matches(ROW)) return; - setTabStop(event.target); + if (!ownsFocus || !(event.target instanceof HTMLElement)) return; + const row = ownerRow(event.target); + if (!row) return; + focusedId = row.dataset.sidebarRow; + focusedParent = row.dataset.sidebarParent; + focusedIndex = visibleRows().indexOf(row); }; const onKeyDown = (event: KeyboardEvent) => { const row = event.target; @@ -162,6 +105,7 @@ export function installSidebarTree(root: HTMLElement) { ) return; if (!(row instanceof HTMLElement) || !row.matches(ROW)) return; + const rows = visibleRows(); const index = rows.indexOf(row); if (index < 0) return; let target: HTMLElement | undefined; @@ -219,34 +163,12 @@ export function installSidebarTree(root: HTMLElement) { }), ); }; - refresh(); - const observer = new MutationObserver((records) => { - if ( - records.some( - (record) => - record.type === "attributes" || - [...record.addedNodes, ...record.removedNodes].some( - (node) => - node instanceof Element && - (node.matches(`${ROW}, ${CONTROL}`) || node.querySelector(`${ROW}, ${CONTROL}`)), - ), - ) - ) - refresh(); - }); + const observer = new MutationObserver(restoreFocus); observer.observe(root, { childList: true, subtree: true, attributes: true, - attributeFilter: [ - "data-sidebar-row", - "data-sidebar-parent", - "aria-expanded", - "hidden", - "disabled", - "inert", - "aria-hidden", - ], + attributeFilter: ["hidden", "inert", "aria-hidden"], }); document.addEventListener("focusin", onFocus); document.addEventListener("pointerdown", onPointerDown, true); @@ -254,8 +176,6 @@ export function installSidebarTree(root: HTMLElement) { root.addEventListener("keyup", onKeyUp, true); return () => { observer.disconnect(); - // A later install reads these as the controls' own tab indexes. - for (const [control, tabIndex] of ownTabIndex) setTabIndex(control, tabIndex); document.removeEventListener("focusin", onFocus); document.removeEventListener("pointerdown", onPointerDown, true); root.removeEventListener("keydown", onKeyDown, true); diff --git a/apps/web/src/lib/workspaceFocus.ts b/apps/web/src/lib/workspaceFocus.ts index dcdb8ffdabd89..810d57a9d604e 100644 --- a/apps/web/src/lib/workspaceFocus.ts +++ b/apps/web/src/lib/workspaceFocus.ts @@ -114,7 +114,7 @@ export function createWorkspaceFocusController(doc: Document) { !element.matches(":disabled"); const preferred = Array.from( area.querySelectorAll( - '[data-workspace-focus-default], [role="treeitem"][tabindex="0"], [data-settings-section][tabindex="0"], .xterm-helper-textarea, [contenteditable="true"]', + '[data-workspace-focus-default], [data-sidebar-row][aria-current="page"], [data-settings-section][tabindex="0"], .xterm-helper-textarea, [contenteditable="true"]', ), ).find(belongs); const fallback = Array.from(area.querySelectorAll(FOCUSABLE_SELECTOR)).find( diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index b75291d47f769..ac7afbc8145ac 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -206,6 +206,32 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```"); }); + it("keeps blank lines inside a code block in a list item, quote, or longer fence", () => { + const code = ["import os", "", "", "def main():"]; + const list = new FakeElement("OL").append( + new FakeElement("LI").append(new FakeText("Create it:\n"), renderedCodeBlock(code)), + ); + const quote = new FakeElement("BLOCKQUOTE").append( + new FakeElement("P").append(new FakeText("Note:")), + renderedCodeBlock(code), + ); + const nestedFence = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Example:")), + renderedCodeBlock(["```js", "a()", "", "", "b() ", "```"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(nestedFence))).toBe( + "Example:\n\n````\n```js\na()\n\n\nb() \n```\n````", + ); + + expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(list)))).toBe( + "1. Create it:\n ```\n import os\n\n\n def main():\n ```", + ); + expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(quote)))).toBe( + "> Note:\n>\n> ```\n> import os\n>\n>\n> def main():\n> ```", + ); + }); + it("omits fences when a selection past the last line drags in the whole code block", () => { const container = new FakeElement("DIV").append( renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]), diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 62bbde6d71667..2364a6c77c4e5 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -57,6 +57,14 @@ function codeFenceFor(code: string): string { return "`".repeat(Math.max(3, longestRun + 1)); } +function outsideCodeFences(markdown: string, tidy: (prose: string) => string): string { + // A fence closes only at its own opening run, so split yields [prose, fence, run] triples. + return markdown + .split(/((`{3,})[\s\S]*?(?:\2|$))/) + .map((part, index) => (index % 3 === 0 ? tidy(part) : index % 3 === 1 ? part : "")) + .join(""); +} + function resolveCodeBlockLanguage(pre: Element): string | null { const declared = pre.closest("[data-language]")?.getAttribute("data-language") ?? @@ -120,11 +128,11 @@ function serializeListItem( const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : ""; const bullet = ordered ? `${index}. ` : "- "; const marker = `${bullet}${task}`; - let content = serializeChildren(item, inTableCell) - .replace(/\n{3,}/g, "\n\n") - .trim(); + let content = outsideCodeFences(serializeChildren(item, inTableCell), (prose) => + prose.replace(/\n{3,}/g, "\n\n"), + ).trim(); if (!item.querySelector(":scope > p")) { - content = content.replace(/\n{2,}/g, "\n"); + content = outsideCodeFences(content, (prose) => prose.replace(/\n{2,}/g, "\n")); } // Continuations align after the bullet; task boxes belong to the item text. const continuationIndent = " ".repeat(bullet.length); @@ -143,9 +151,9 @@ function serializeList(list: Element, ordered: boolean, inTableCell: boolean): s } function serializeBlockquote(quote: Element, inTableCell: boolean): string { - const content = serializeChildren(quote, inTableCell) - .replace(/\n{3,}/g, "\n\n") - .trim(); + const content = outsideCodeFences(serializeChildren(quote, inTableCell), (prose) => + prose.replace(/\n{3,}/g, "\n\n"), + ).trim(); if (!content) return ""; const quoted = content .split("\n") @@ -316,13 +324,9 @@ function soleCodeBlock(container: Node): Element | null { } function tidyMarkdown(markdown: string): string { - return markdown - .split(/(```[\s\S]*?(?:```|$))/) - .map((part, index) => - index % 2 === 1 ? part : part.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"), - ) - .join("") - .trim(); + return outsideCodeFences(markdown, (prose) => + prose.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"), + ).trim(); } export function serializeRenderedMarkdownFragment(container: Node): string { diff --git a/apps/web/src/reviewCommentContext.test.ts b/apps/web/src/reviewCommentContext.test.ts index b17d4ef106e03..1b5e64373bf1a 100644 --- a/apps/web/src/reviewCommentContext.test.ts +++ b/apps/web/src/reviewCommentContext.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDiffReviewComment, buildFileReviewComment, + buildReviewCommentRenderablePatch, formatReviewCommentFence, resolveDiffReviewAnchor, resolveDiffReviewPosition, @@ -75,6 +76,38 @@ describe("review comment context parsing", () => { ); }); + it("keeps a selection's trailing blank line and whitespace in the rendered excerpt", () => { + const fileDiff = parsePatchFiles( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,4 +1,4 @@", + " one", + "-two", + "+two\t", + " ", + " four", + ].join("\n"), + "review-comment-trailing-blank", + )[0]!.files[0]!; + const comment = (end: number) => + buildDiffReviewComment({ + id: "comment-blank", + sectionId: "turn:1", + sectionTitle: "Turn 1", + filePath: "src/app.ts", + fileDiff, + range: { start: 2, side: "additions", end, endSide: "additions" }, + text: "Insert here.", + })!; + + expect(buildReviewCommentRenderablePatch(comment(2))).toMatch(/\n\+two\t$/); + expect(buildReviewCommentRenderablePatch(comment(3))).toMatch( + /\n@@ -3,1 \+2,2 @@\n\+two\t\n $/, + ); + }); + it("restores Pierre line selections from persisted diff comment row indexes", () => { const fileDiff = parsePatchFiles( [ diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 336a506a52676..b169dd41885cf 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -512,10 +512,10 @@ export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext) if ((comment.fenceLanguage ?? "diff") !== "diff") { return ""; } - const diff = comment.diff.trim(); - if (diff.length === 0) { + if (comment.diff.trim().length === 0) { return ""; } + const diff = comment.diff.trimStart().replace(/\n+$/, ""); if (diff.startsWith("diff --git ")) { return diff; } diff --git a/apps/web/src/shortcuts/chatRouteCommands.test.ts b/apps/web/src/shortcuts/chatRouteCommands.test.ts new file mode 100644 index 0000000000000..6c9ff18690ec4 --- /dev/null +++ b/apps/web/src/shortcuts/chatRouteCommands.test.ts @@ -0,0 +1,65 @@ +import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { AnyRouter } from "@tanstack/react-router"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import * as commandPalette from "../commandPaletteBus"; +import * as settings from "../hooks/useSettings"; +import * as threadActions from "../lib/chatThreadActions"; +import * as entities from "../state/entities"; +import type { Project } from "../types"; +import { installChatRouteCommands } from "./chatRouteCommands"; +import * as dispatcher from "./dispatcher"; + +function project(id: string): Project { + return { + id: ProjectId.make(id), + environmentId: EnvironmentId.make("local"), + title: id, + workspaceRoot: `/projects/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + }; +} + +describe("new thread commands", () => { + it.each([ + { mode: "normal", count: 2, command: "chat.new", picker: true }, + { mode: "boring", count: 2, command: "chat.new", picker: true }, + { mode: "boring", count: 1, command: "chat.new", picker: false }, + { mode: "legacy", count: 2, command: "chat.new", picker: false }, + { mode: "boring", count: 2, command: "chat.newLocal", picker: false }, + ] as const)("$mode sidebar, $count projects, $command: picker=$picker", (scenario) => { + using _settings = vi.spyOn(settings, "getClientSettings").mockReturnValue({ + ...DEFAULT_CLIENT_SETTINGS, + boringSidebarEnabled: scenario.mode === "boring", + legacySidebarEnabled: scenario.mode === "legacy", + }); + using _projects = vi + .spyOn(entities, "readProjects") + .mockReturnValue([project("active"), project("idle")].slice(0, scenario.count)); + using openPicker = vi.spyOn(commandPalette, "openCommandPalette").mockImplementation(() => {}); + using startLocal = vi.spyOn(threadActions, "startNewThreadFromContext").mockResolvedValue(true); + using register = vi.spyOn(dispatcher, "registerCommands").mockReturnValue(() => {}); + const router = { state: { matches: [] } } as unknown as AnyRouter; + using stack = new DisposableStack(); + stack.defer(installChatRouteCommands(router)); + const commands = register.mock.calls[0]![0]; + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + + commands[scenario.command]!(event); + + if (scenario.picker) { + expect(openPicker).toHaveBeenCalledWith({ open: "new-thread-in" }); + expect(startLocal).not.toHaveBeenCalled(); + } else { + expect(openPicker).not.toHaveBeenCalled(); + expect(startLocal).toHaveBeenCalledOnce(); + } + }); +}); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index 5ddb93f30e8b2..cb9474c9a9237 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -123,12 +123,9 @@ export function installChatRouteCommands(router: AnyRouter): () => void { void scratch.startScratchThread(environmentId); }, "chat.new": (event) => { - // The boring sidebar's folders already show the context, so it starts in - // the current project like the legacy sidebar did. - const { boringSidebarEnabled, legacySidebarEnabled } = getClientSettings(); + const { legacySidebarEnabled } = getClientSettings(); const groups = readProjectGroups(); if ( - !boringSidebarEnabled && !legacySidebarEnabled && !shouldCreateNewThreadInCurrentProject( false, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 4dbd65b0abc51..5f9f7d981da7d 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,4 +1,8 @@ -import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import type { + EnvironmentId, + ScopedProjectRef, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -7,6 +11,23 @@ import { } from "@t3tools/client-runtime/state/project-grouping"; import type { Project } from "./types"; +export const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; + +export function withProjectGroupingOverride( + overrides: Readonly> | undefined, + overrideKey: string, + selection: SidebarProjectGroupingMode | "inherit", +): Record { + const next = { ...overrides }; + if (selection === "inherit") delete next[overrideKey]; + else next[overrideKey] = selection; + return next; +} + export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; export interface SidebarProjectGroupMember extends Project { diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts new file mode 100644 index 0000000000000..5b5e11d812027 --- /dev/null +++ b/apps/web/src/timestampFormat.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatDayAwareTimestamp, formatUpcomingTimestamp } from "./timestampFormat"; + +describe("timestamps in an explicit time zone", () => { + // 23:00 UTC on July 1 is 7 PM on July 1 in New York, whatever the host zone. + const now = Date.parse("2026-07-01T23:00:00.000Z"); + const zone = "America/New_York"; + + it("picks relative days from the calendar in that zone", () => { + expect(formatUpcomingTimestamp("2026-07-02T00:30:00.000Z", "12-hour", now, zone)).not.toMatch( + /tomorrow/, + ); + expect(formatUpcomingTimestamp("2026-07-02T05:30:00.000Z", "12-hour", now, zone)).toMatch( + /^tomorrow at /, + ); + expect(formatDayAwareTimestamp("2026-07-01T03:30:00.000Z", "12-hour", now, zone)).toMatch( + /^yesterday at /, + ); + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index b1a8121677a5e..abad91401ccbe 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -73,17 +73,18 @@ const timestampFormatterCache = new Map(); function getTimestampFormatter( timestampFormat: TimestampFormat, includeSeconds: boolean, + timeZone?: string, ): Intl.DateTimeFormat { - const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; + const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone ?? ""}`; const cachedFormatter = timestampFormatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; } - const formatter = new Intl.DateTimeFormat( - timestampLocale, - getTimestampFormatOptions(timestampFormat, includeSeconds), - ); + const formatter = new Intl.DateTimeFormat(timestampLocale, { + ...getTimestampFormatOptions(timestampFormat, includeSeconds), + ...(timeZone ? { timeZone } : {}), + }); timestampFormatterCache.set(cacheKey, formatter); return formatter; } @@ -136,58 +137,88 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp return getTimestampFormatter(timestampFormat, false).format(date); } -const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, { - month: "numeric", - day: "numeric", -}); -const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, { - month: "numeric", - day: "numeric", +const numericDateOptions: Intl.DateTimeFormatOptions = { month: "numeric", day: "numeric" }; +const numericDateWithYearOptions: Intl.DateTimeFormatOptions = { + ...numericDateOptions, year: "numeric", -}); +}; +const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, numericDateOptions); +const numericDateWithYearFormatter = new Intl.DateTimeFormat( + timestampLocale, + numericDateWithYearOptions, +); + +interface CalendarDay { + year: number; + /** Days since the epoch, so differences are whole days across DST shifts. */ + index: number; +} + +function calendarDay(date: Date, timeZone: string | undefined): CalendarDay { + if (!timeZone) { + const year = date.getFullYear(); + return { year, index: Date.UTC(year, date.getMonth(), date.getDate()) / 86_400_000 }; + } + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "numeric", + day: "numeric", + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((entry) => entry.type === type)?.value); + const year = part("year"); + return { year, index: Date.UTC(year, part("month") - 1, part("day")) / 86_400_000 }; +} + +function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | undefined): string { + if (!timeZone) { + return (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format(date); + } + return new Intl.DateTimeFormat(timestampLocale, { + ...(sameYear ? numericDateOptions : numericDateWithYearOptions), + timeZone, + }).format(date); +} export function formatDayAwareTimestamp( isoDate: string, timestampFormat: TimestampFormat, nowMs: number = Date.now(), + timeZone?: string, ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false).format(date); + const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); - const now = new Date(nowMs); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); - // Round so DST-shifted 23/25 hour days still count as whole days. - const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000); + const today = calendarDay(new Date(nowMs), timeZone); + const messageDay = calendarDay(date, timeZone); + const dayDiff = today.index - messageDay.index; if (dayDiff <= 0) return time; if (dayDiff === 1) return `yesterday at ${time}`; - const dateFormatter = - date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; - return `${dateFormatter.format(date)} ${time}`; + return `${formatNumericDate(date, messageDay.year === today.year, timeZone)} ${time}`; } +/** `timeZone` overrides the zone the formatters cached when they were built. */ export function formatUpcomingTimestamp( isoDate: string, timestampFormat: TimestampFormat, nowMs: number = Date.now(), + timeZone?: string, ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false).format(date); + const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); - const now = new Date(nowMs); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const startOfTargetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); - const dayDiff = Math.round((startOfTargetDay - startOfToday) / 86_400_000); + const today = calendarDay(new Date(nowMs), timeZone); + const targetDay = calendarDay(date, timeZone); + const dayDiff = targetDay.index - today.index; - if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs); + if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs, timeZone); if (dayDiff === 0) return time; if (dayDiff === 1) return `tomorrow at ${time}`; - const dateFormatter = - date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; - return `${dateFormatter.format(date)} ${time}`; + return `${formatNumericDate(date, targetDay.year === today.year, timeZone)} ${time}`; } type RelativeTimeParts = { value: string; suffix: string | null }; diff --git a/apps/web/test/fixtures/terminal-layout/capture.mjs b/apps/web/test/fixtures/terminal-layout/capture.mjs new file mode 100644 index 0000000000000..b1afa18051aa6 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/capture.mjs @@ -0,0 +1,52 @@ +export const frozenTime = Date.UTC(2026, 9, 21, 22, 30); + +// Freeze wall-clock labels without changing animation or recording timing. +export function freezeClock() { + const RealDate = globalThis.__evidenceRealDate ?? Date; + globalThis.__evidenceRealDate = RealDate; + globalThis.Date = class extends RealDate { + constructor(...args) { + super(...(args.length ? args : [frozenTime])); + } + static now() { + return frozenTime; + } + }; +} + +export async function metadata(provenance) { + await document.fonts.ready; + const box = (selector) => + [...document.querySelectorAll(selector)].map((el) => { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }); + return { + ...provenance, + userAgent: navigator.userAgent, + platform: navigator.platform, + locale: navigator.language, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + frozenTime: new Date().toISOString(), + devicePixelRatio, + viewport: { width: innerWidth, height: innerHeight }, + visualViewportScale: visualViewport.scale, + rootFontSize: getComputedStyle(document.documentElement).fontSize, + rootClass: document.documentElement.className, + fonts: [...document.fonts] + .filter((f) => f.status === "loaded") + .map((f) => ({ family: f.family, style: f.style, weight: f.weight })), + terminal: box("[data-thread-terminal-drawer]"), + canvases: box("canvas"), + composer: box('[role="textbox"][contenteditable="true"]'), + scroll: [...document.querySelectorAll("*")] + .filter((e) => e.scrollTop || e.scrollLeft) + .map((e) => ({ + tag: e.tagName, + className: e.className, + top: e.scrollTop, + left: e.scrollLeft, + })), + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + }; +} diff --git a/apps/web/test/fixtures/terminal-layout/driver.mjs b/apps/web/test/fixtures/terminal-layout/driver.mjs new file mode 100644 index 0000000000000..04b93e1a07cc5 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/driver.mjs @@ -0,0 +1,85 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +// The caller supplies the harness's T3 preview tool dispatcher, not Playwright. +export async function createDriver( + callPreview, + { tabId, outputDir, repoDir, run, phase, sourceTreeSha }, +) { + await NodeFSP.mkdir(outputDir, { recursive: true }); + await NodeFSP.mkdir("/tmp/t3-terminal-evidence", { recursive: true }); + const events = []; + const captures = []; + const started = Date.now(); + const git = (...args) => + NodeChildProcess.execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }).trim(); + const provenance = { + headSha: git("rev-parse", "HEAD"), + sourceTreeSha, + terminalBlob: git("hash-object", "apps/web/src/components/ThreadTerminalDrawer.tsx"), + workingTreeStatus: git("status", "--short"), + phase, + }; + let milestones; + let pendingMilestones; + const decode = (result) => + result.structuredContent ?? + JSON.parse(result.content.find((item) => item.type === "text").text); + // Watch before the action that starts a shell, so fast prompts cannot race the watcher. + const arm = async () => { + await milestones?.return(); + milestones = NodeFSP.watch("/tmp/t3-terminal-evidence"); + pendingMilestones = milestones.next(); + }; + const call = async (name, args = {}) => { + if (name === "press" && args.key === "Enter") await arm(); + const result = await callPreview(name, { ...args, tabId }); + events.push({ run, ms: Date.now() - started, op: name, args, isError: !!result.isError }); + if (result.isError) throw new Error(JSON.stringify(result)); + return decode(result); + }; + const evaluate = async (expression) => (await call("evaluate", { expression })).value; + const driver = { + call, + evaluate, + arm, + ready: async (pane) => { + if (!milestones) throw new Error(`Terminal readiness for ${pane} was not armed`); + for (;;) { + const event = await pendingMilestones; + if (event.done) throw new Error("Terminal readiness watcher ended"); + if (event.value.filename === `ready-${pane}`) break; + pendingMilestones = milestones.next(); + } + await milestones.return(); + milestones = undefined; + pendingMilestones = undefined; + }, + columns: async () => + Number( + (await NodeFSP.readFile("/tmp/t3-terminal-evidence/input-size", "utf8")) + .trim() + .split(/\s+/)[1], + ), + note: async (note, data) => events.push({ run, ms: Date.now() - started, note, data }), + checkpoint: async (checkpoint) => { + const meta = await evaluate( + `(async()=>{const m=await import('/test/fixtures/terminal-layout/capture.mjs');return m.metadata(${JSON.stringify(provenance)})})()`, + ); + const snapshot = await call("snapshot", { save: true }); + const target = NodePath.join(outputDir, `${run}-${checkpoint}.png`); + await NodeFSP.copyFile(snapshot.screenshotPath, target); + captures.push({ run, checkpoint, path: target, meta }); + events.push({ run, ms: Date.now() - started, checkpoint, path: target }); + }, + finish: async () => { + await milestones?.return(); + await NodeFSP.writeFile( + NodePath.join(outputDir, `${run}.json`), + JSON.stringify({ provenance, events, captures }, null, 2), + ); + }, + }; + return driver; +} diff --git a/apps/web/test/fixtures/terminal-layout/manifest.json b/apps/web/test/fixtures/terminal-layout/manifest.json new file mode 100644 index 0000000000000..bc45d58077942 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/manifest.json @@ -0,0 +1,38 @@ +{ + "frozenUtc": "2026-10-21T22:30:00.000Z", + "viewports": { + "W": [1440, 900], + "N": [390, 900] + }, + "themes": ["light", "dark"], + "static": ["P1-W-L", "P1-W-D", "P1-N-L", "P1-N-D", "D1-W-L", "D1-W-D", "D1-N-L", "D1-N-D"], + "recordings": ["RP-W-D", "RP-N-L", "RD-W-D", "RD-N-L"], + "permissionUnavailable": ["Q1-N-L", "Q2-W-D"], + "shellCommand": "bash /apps/web/test/fixtures/terminal-layout/shell.sh A", + "drawerHeight": 280, + "diff": { + "zero": { + "threshold": 0, + "gain": 8 + }, + "thresholded": { + "threshold": 8, + "gain": 8 + }, + "raw": { + "threshold": 0, + "gain": 1 + } + }, + "historical": { + "baselineApp": "70e5e384dab96a1d1e4a253ee5a9415ed5d8ce30", + "afterApp": "ec679087f9029cc9be36fc76e1f95e2ffb692dce" + }, + "baselineApp": "b5a226e49c52ecab4ddc2d678a7012d97e651a6a", + "afterApp": "Resolve HEAD at capture time; includes finalized fixtures", + "terminalBlobs": { + "before": "0a12a6535c2d56325ef3193068b44c5ef08eb4b2", + "after": "9626c2640b7d20fc38c972ed6fd3af250a434c16" + }, + "setup": "Pair an isolated dev server; createDriver dispatches T3 preview tools for one owned tab; setup creates a fresh draft and shell. Call replay with the returned threadRef/threadKey. Use typeFirstLine for pending-input captures. Stop the server before seed.mjs." +} diff --git a/apps/web/test/fixtures/terminal-layout/replay.mjs b/apps/web/test/fixtures/terminal-layout/replay.mjs new file mode 100644 index 0000000000000..752a06a7ab361 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/replay.mjs @@ -0,0 +1,196 @@ +// The driver invokes only T3 preview tools. Each checkpoint saves the whole viewport. +export async function replay(driver, { mode, width, shellPath, threadRef, threadKey }) { + const owner = mode === "RP" ? "right-panel" : "drawer"; + const surface = `[data-terminal-owner="${owner}"]`; + const action = (name) => `${surface} button[aria-label^="${name}"]`; + const call = driver.call; + const read = driver.evaluate; + const checkpoint = driver.checkpoint; + await read( + `globalThis.driverThreadRef = ${JSON.stringify(threadRef)}; globalThis.driverThreadKey = ${JSON.stringify(threadKey)}`, + ); + const press = (key, modifiers) => call("press", { key, modifiers }); + const click = (locator) => call("click", { locator }); + const quotedShellPath = `'${shellPath.replaceAll("'", "'\\''")}'`; + const command = async (text) => { + await press("c", ["Control"]); + await call("type", { locator: `${surface} textarea:focus`, text }); + await press("Enter"); + }; + const seed = async (pane, index) => { + await click(`${surface} canvas >> nth=${index}`); + await command(`bash ${quotedShellPath} ${pane}`); + await driver.ready(pane); + }; + const reset = async () => { + const n = await read(`document.querySelectorAll('${surface} canvas').length`); + for (let i = 0; i < n; i++) { + await click(`${surface} canvas >> nth=${i}`); + await command("fixture_reset"); + await driver.ready(String.fromCharCode(65 + i)); + } + }; + const close = async () => { + await click(action("Close Terminal (")); + await call("wait_for", { locator: '[role="alertdialog"]' }); + await click('role=button[name="Confirm"]'); + await call("wait_for", { locator: `${surface} canvas` }); + }; + await reset(); + await checkpoint("single"); + for (const name of [ + "Split Terminal Horizontally", + "Split Terminal Vertically", + "New Terminal", + "Close Terminal (", + ]) { + await call("hover", { locator: action(name) }); + await call("wait_for", { locator: '[role="dialog"]' }); + } + await checkpoint("tooltip"); + const hit = await read( + `(()=>{const b=document.querySelector('${action("Split Terminal Horizontally")}');const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+2,label:b.getAttribute('aria-label'),hit:document.elementFromPoint(r.x+r.width/2,r.y+2)?.closest('button')?.getAttribute('aria-label')}})()`, + ); + await driver.note("top2px horizontal split hit test", hit); + await call("click", { x: hit.x, y: hit.y }); + await seed("B", 1); + await reset(); + await checkpoint("S-H"); + for (const [pane, index] of [ + ["C", 2], + ["D", 3], + ]) { + await click(action("Split Terminal Horizontally")); + await seed(pane, index); + } + await reset(); + await call("hover", { locator: action("Split Terminal Horizontally") }); + await call("wait_for", { text: "max 4 per group" }); + try { + await click(action("Split Terminal Horizontally")); + } catch (error) { + await driver.note("Limit control rejected the pointer attempt", { error: String(error) }); + } + const count = await read(`document.querySelectorAll('${surface} canvas').length`); + if (count !== 4) throw Error(`Expected four panes, received ${count}`); + await checkpoint("L"); + for (let i = 0; i < 3; i++) await close(); + await reset(); + await checkpoint("single-after-horizontal"); + await click(action("Split Terminal Vertically")); + await seed("B", 1); + await reset(); + await checkpoint("S-V"); + await close(); + await reset(); + await click(action("New Terminal")); + await seed("B", 0); + await checkpoint(mode === "RP" ? "new-tab" : "G"); + if (mode === "RP") { + const tab = (index) => + `[data-right-panel-tab-list] [data-active-tab] button:not([aria-label]):visible >> nth=${index}`; + await click(tab(0)); + await reset(); + await click(tab(1)); + await close(); + if (width > 980) { + await call("drag", { + source: '[role="separator"].cursor-col-resize:visible', + target: '[role="textbox"][contenteditable="true"]', + }); + await checkpoint("width-drag"); + await click('button[aria-label="Maximize panel"]:visible'); + await checkpoint("maximized"); + await click('button[aria-label="Restore panel size"]:visible'); + await call("drag", { + source: '[role="separator"].cursor-col-resize:visible', + target: 'button[aria-label="Toggle right panel"]:visible', + }); + } + await click('button[aria-label="Toggle right panel"]:visible'); + await checkpoint("hidden"); + await click('button[aria-label="Toggle right panel"]:visible'); + } else { + const session = (index) => + `${surface} [class~="group/tab"] > button:not([aria-label]) >> nth=${index}`; + await click(session(0)); + await reset(); + await click(session(1)); + await close(); + await click('button[aria-label="Toggle thread details panel"]:visible'); + await driver.arm(); + await click( + 'button[aria-label="Run Evidence deterministic terminal with deliberately long label"]', + ); + await call("wait_for", { locator: `${surface} canvas` }); + await click('button[aria-label="Toggle thread details panel"]:visible'); + await driver.ready("G"); + await checkpoint("G-long-label"); + await close(); + await click(`${surface} canvas`); + await press("d", ["Control"]); + await seed("B", 1); + await checkpoint("shortcut-split"); + await close(); + await call("drag", { source: `${surface} > .cursor-row-resize`, target: `${surface} canvas` }); + await reset(); + await checkpoint("minimum-height"); + await read( + `(async()=>{const m=await import('/src/terminalUiStateStore.ts');m.useTerminalUiStateStore.getState().setTerminalHeight(driverThreadRef,280)})()`, + ); + await click('button[aria-label="Toggle terminal drawer"]:visible'); + await click('button[aria-label="Toggle terminal drawer"]:visible'); + await call("wait_for", { locator: `${surface} canvas` }); + await driver.note("restore preferred height through existing store action", { height: 280 }); + await call("type", { + locator: '[role="textbox"][contenteditable="true"]', + text: Array.from({ length: 12 }, (_, i) => "Composer fixture line " + (i + 1)).join("\n"), + }); + await read( + `(async()=>{const m=await import('/src/hooks/useSettings.ts');await m.updateClientSettings({fontSizeTerminal:18})})()`, + ); + await reset(); + await checkpoint("constrained-font18"); + await call("type", { + locator: '[role="textbox"][contenteditable="true"]', + text: "", + clear: true, + }); + await read( + `(async()=>{const m=await import('/src/hooks/useSettings.ts');await m.updateClientSettings({fontSizeTerminal:12})})()`, + ); + await click(`${surface} canvas`); + await command("fixture_scrollback"); + await driver.ready("A"); + await read( + `document.querySelector('${surface} canvas').dispatchEvent(new WheelEvent('wheel',{deltaY:-1000,bubbles:true,cancelable:true}))`, + ); + await checkpoint("scroll-up"); + await read( + `document.querySelector('${surface} canvas').dispatchEvent(new WheelEvent('wheel',{deltaY:1000,bubbles:true,cancelable:true}))`, + ); + await checkpoint("scroll-down"); + await click('button[aria-label="Toggle terminal drawer"]:visible'); + await checkpoint("hidden"); + await click('button[aria-label="Toggle terminal drawer"]:visible'); + } + await reset(); + await checkpoint("reopened"); + await click(action("Close Terminal (")); + await click('role=button[name="Cancel"]'); + await checkpoint("close-cancel"); + await click(action("Close Terminal (")); + await click('role=button[name="Confirm"]'); + await checkpoint("E-last-close"); + if (mode === "RP") { + const open = await read( + `JSON.parse(localStorage.getItem('t3code:right-panel-state:v2')).state.byThreadKey[driverThreadKey]?.isOpen === true`, + ); + if (!open) await click('button[aria-label="Toggle right panel"]:visible'); + await click('role=button[name="Terminal T"]'); + } else { + await click('button[aria-label="Toggle terminal drawer"]:visible'); + } + await seed("A", 0); + await checkpoint("new-usable-single"); +} diff --git a/apps/web/test/fixtures/terminal-layout/seed.mjs b/apps/web/test/fixtures/terminal-layout/seed.mjs new file mode 100644 index 0000000000000..37978c0a8891d --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/seed.mjs @@ -0,0 +1,36 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +// Run with the isolated server stopped: node seed.mjs . +// The app creates projects/drafts normally; this adds only the named script. +const [home, projectId] = process.argv.slice(2); +if (!home || !projectId) throw new Error("Usage: node seed.mjs "); +const resolvedHome = await NodeFSP.realpath(home); +const liveHome = await NodeFSP.realpath(NodePath.join(NodeOS.homedir(), ".t3")); +if (resolvedHome === liveHome || resolvedHome.startsWith(liveHome + NodePath.sep)) { + throw new Error("Refusing to write live T3 state"); +} +const target = NodePath.join(resolvedHome, "userdata/settings.json"); +const settings = JSON.parse( + await NodeFSP.readFile(target, "utf8").catch((error) => { + if (error.code === "ENOENT") return "{}"; + throw error; + }), +); +const shellPath = NodeURL.fileURLToPath(new URL("./shell.sh", import.meta.url)); +const script = { + id: "evidence-deterministic-t", + name: "Evidence deterministic terminal with deliberately long label", + command: `bash '${shellPath.replaceAll("'", "'\\''")}' G`, + icon: "play", + runOnWorktreeCreate: false, +}; +settings.projectScriptOverrides ??= {}; +const previous = settings.projectScriptOverrides[projectId] ?? []; +settings.projectScriptOverrides[projectId] = [ + ...previous.filter((item) => item.id !== script.id), + script, +]; +await NodeFSP.writeFile(target, JSON.stringify(settings, null, 2) + "\n"); diff --git a/apps/web/test/fixtures/terminal-layout/setup.mjs b/apps/web/test/fixtures/terminal-layout/setup.mjs new file mode 100644 index 0000000000000..521bbf146e41d --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/setup.mjs @@ -0,0 +1,62 @@ +// Start an isolated dev server and pair its Browser panel before calling this. +// callPreview dispatches names to T3 preview_* tools; it must keep one owned tab. +export async function setup(driver, { width, theme, mode, shellPath, projectName = "server" }) { + const { call, evaluate } = driver; + await call("resize", { mode: "freeform", width, height: 900 }); + await call("set_appearance", { colorScheme: theme }); + await evaluate(`(async()=>{ + const capture=await import('/test/fixtures/terminal-layout/capture.mjs'); + capture.freezeClock(); + const settings=await import('/src/hooks/useSettings.ts'); + await settings.updateClientSettings({fontSizeTerminal:12}); + localStorage.setItem('t3code:theme',${JSON.stringify(theme)}); + window.dispatchEvent(new StorageEvent('storage',{key:'t3code:theme',newValue:${JSON.stringify(theme)}})); + })()`); + await call("click", { + locator: `button[aria-label=${JSON.stringify(`New thread in ${projectName}`)}]`, + }); + await call("wait_for", { locator: '[role="textbox"][contenteditable="true"]' }); + const context = await evaluate(`(()=>{ + const draftId=location.pathname.split('/').at(-1); + const draft=JSON.parse(localStorage['t3code:composer-drafts:v1']).state.draftThreadsByThreadKey[draftId]; + if(!draft) throw Error('Expected a newly created draft'); + const threadRef={environmentId:draft.environmentId,threadId:draft.threadId}; + return {route:location.pathname,threadRef,threadKey:draft.environmentId+':'+draft.threadId}; + })()`); + const sidebarOpen = await evaluate( + `document.querySelector('button[aria-label="Toggle main sidebar"]')?.getAttribute('aria-pressed')==='true'`, + ); + if (sidebarOpen) await call("click", { locator: 'button[aria-label="Toggle main sidebar"]' }); + if (mode === "RP") { + await call("click", { locator: 'button[aria-label="Toggle right panel"]' }); + await call("click", { locator: 'role=button[name="Terminal T"]' }); + } else { + await call("click", { locator: 'button[aria-label="Toggle terminal drawer"]' }); + } + const surface = `[data-terminal-owner="${mode === "RP" ? "right-panel" : "drawer"}"]`; + await call("wait_for", { locator: `${surface} canvas` }); + await call("click", { locator: `${surface} canvas` }); + await call("press", { key: "c", modifiers: ["Control"] }); + const quotedPath = `'${shellPath.replaceAll("'", "'\\''")}'`; + await call("type", { locator: `${surface} textarea:focus`, text: `bash ${quotedPath} A` }); + await call("press", { key: "Enter" }); + await driver.ready("A"); + return context; +} + +// This leaves real unexecuted shell input and its cursor on the first row. +export async function typeFirstLine(driver, { mode, columns }) { + const surface = `[data-terminal-owner="${mode === "RP" ? "right-panel" : "drawer"}"]`; + await driver.call("click", { locator: `${surface} canvas` }); + await driver.call("press", { key: "c", modifiers: ["Control"] }); + await driver.call("type", { locator: `${surface} textarea:focus`, text: "fixture_input" }); + await driver.call("press", { key: "Enter" }); + await driver.ready("input"); + const terminalColumns = columns ?? (await driver.columns()); + const prefix = "printf '%s\\n' '"; + const suffix = "RIGHT'"; + const text = + prefix + "typed-".padEnd(terminalColumns - 2 - prefix.length - suffix.length, "x") + suffix; + await driver.call("type", { locator: `${surface} textarea:focus`, text }); + return text; +} diff --git a/apps/web/test/fixtures/terminal-layout/shell.sh b/apps/web/test/fixtures/terminal-layout/shell.sh new file mode 100644 index 0000000000000..833fe434b7100 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/shell.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + set -eu + mkdir -p /tmp/t3-terminal-evidence + exec env -i HOME=/tmp/t3-terminal-evidence PATH=/usr/bin:/bin TERM=xterm-256color LANG=C.UTF-8 LC_ALL=C.UTF-8 EVIDENCE_PANE="${1:-A}" bash --noprofile --rcfile "$0" -i +fi +cd /tmp/t3-terminal-evidence +HISTFILE=/dev/null +set +o history +fixture_prompt() { + local width fill + width=$(stty size); width=${width##* } + printf -v fill '%*s' "$((width > 12 ? width - 12 : 1))" '' + PS1="\[\e[36m\]LEFT-${fill// /-}-RIGHT\[\e[0m\]\n\[\e[32m\]pane ${EVIDENCE_PANE} READY\[\e[0m\] ANSI blue: \[\e[34m\]0123456789\[\e[0m\]\nevidence:/fixture $ " + printf '\033[?25l' + stty size > /tmp/t3-terminal-evidence/input-size + printf '%s\n' "$EVIDENCE_PANE" > "/tmp/t3-terminal-evidence/ready-${EVIDENCE_PANE}" +} +fixture_reset() { printf '\033[3J\033[H\033[2J'; } +fixture_scrollback() { + local n + for ((n=1;n<=80;n++)); do printf 'SCROLL %03d | deterministic terminal output | RIGHT\n' "$n"; done +} +PROMPT_COMMAND=fixture_prompt +fixture_reset + +# Leave the next command on the first physical line with a steady visible cursor. +fixture_input() { + PROMPT_COMMAND= + PS1= + printf '\033[3J\033[H\033[2J\033[?25h\033[2 q' + stty size > /tmp/t3-terminal-evidence/input-size + printf 'input\n' > /tmp/t3-terminal-evidence/ready-input +} diff --git a/docs/README.md b/docs/README.md index af3c45ce567b1..f332da947f55f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,7 @@ - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) - [Triage an issue](./user/triage.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Kiro](./user/providers-kiro.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) --- diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 6a26f9e6071e0..f24e3625d92f8 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -44,8 +44,8 @@ select known environment-local checkouts; the group itself does not store inheri ## Durable intent and side effects -Events record orchestration decisions, and persisted projections hold the current read state. The -[v2 orchestrator](../../apps/server/src/orchestration-v2/Orchestrator.ts) serializes commands and +Orchestration state is decided as events, and projections are the source of truth once they apply +them. The [v2 orchestrator](../../apps/server/src/orchestration-v2/Orchestrator.ts) serializes commands and decides events without performing provider or filesystem work. [EventSink](../../apps/server/src/orchestration-v2/EventSink.ts) commits events, persisted projections, the accepted command receipt, and outbox effects in one database transaction. Subscribers receive @@ -58,17 +58,24 @@ therefore means the intent committed, not that the provider, checkpoint, or othe finished. Keep external I/O out of command decisions and the database transaction. Effects tied to a lost provider process cannot simply replay; recovery retires them before admitting new work. -Persisted events must remain decodable on replay. Changing a schema affects old environments at +The event log only keeps what live readers need: about a day of history for reconnect replay and +command-id retries. +[ProjectionMaintenance](../../apps/server/src/orchestration-v2/ProjectionMaintenance.ts) deletes +older events the projections cover, so projections cannot be rebuilt from the log. It keeps each +stream's newest event (high-water marks, stream versions, snapshot sequences), `thread.created` +(the legacy importer's marker), every `running` run update (Codex usage history reads the first +of each attempt), and prompt anchors that retained subagent rows reference. A client whose cursor +is below both its thread's newest event and the recorded pruned-through sequence missed pruned +events, so it resumes from a snapshot; an idle thread's cursor resumes without one. Resumes +recheck that sequence after reading the replay, since a pass can prune during the read. V2 command +receipts stay for idempotency: a retry older than the retained history is still deduplicated, +but its replay comes back without the pruned events. Receipts of imported legacy commands are +removed once their threads are imported. + +Retained events must remain decodable on replay. Changing a schema affects old environments at startup as well as live RPC traffic. Compatibility work must account for stored history, not just what the newest client sends. -The log is not kept forever. [Compaction](../../apps/server/src/orchestration-v2/ProjectionMaintenance.ts) -deletes thread events older than a day, except anchors that other readers need, and raises the -thread's replay floor in the same transaction. A cursor below that floor gets a snapshot. Projections -cannot be rebuilt from events once anything has been pruned, so future data migrations must update -projections directly. Builds from before retention ignore replay floors and must not open a pruned -database. - ## Turn completion and checkpoints A provider turn ending and its follow-up work settling are separate milestones. Orchestration diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index 4c9bbcb780bee..1d577d7f0d7dd 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -13,22 +13,30 @@ of its traces and leaves token and signature verification to the environment By default the forwarder stores nothing. An environment can opt in to having the relay hold requests while it is offline -(`hold_webhooks_while_offline` on its link). Only then does the relay store the -raw request, including the hook token in the path, in a Durable Object for -that environment, with SQLite storage. The object pushes held requests back -through the tunnel from its alarm, oldest first, and backs off while the -environment stays away. When the tunnel reconnects, the environment asks the -relay to deliver right away. Requests are deleted once the environment -answers, after 24 hours, or when no user has the environment linked. The relay -still never checks the token; delivery goes through the same environment route. +(`hold_webhooks_while_offline` on its links). Only then does the relay store the +raw request, including the hook token in the path, in a Durable Object with +SQLite storage. There is one object per managed link, keyed by its endpoint +key: each account that links the environment gets its own held requests, +order, and caps. The object pushes held requests back through the tunnel from +its alarm, oldest first within each hook, and backs off while the environment +stays away. A hook that answers busy is skipped for the rest of that run, so +other hooks on the link can overtake it. When +the tunnel reconnects, the environment asks the relay to deliver right away to +each of its links. Requests are deleted once the environment answers, after 24 +hours, when their link is unlinked, or when the environment turns holding off, +which clears every link it proved. Both clears are best effort: unlinking logs a +failed clear and continues, and turning holding off stops at the first link it +cannot clear, so those requests wait for the 24-hour expiry. The relay still +never checks the token; +delivery goes through the same environment route. Every forward carries `x-t3-relay-delivery-id`, so a request that reached the environment before a timeout and is delivered again later runs once ([inbox object](../../infra/relay/src/hooks/HookInboxObject.ts)). A Durable Object, not Postgres or Queues, because held requests are write-once, -read-once bodies of up to 1 MiB that need per-environment order, caps, and -retry timing. Queues cap messages at 128 KB and cannot hold one environment's -requests back while it is away. +read-once bodies of up to 1 MiB that need per-link order, caps, and retry +timing. Queues cap messages at 128 KB and cannot hold one link's requests back +while its environment is away. Clerk, deployment, and native authentication setup live in the [Connect setup runbook](../operations/connect-setup.md). diff --git a/docs/operations/development.md b/docs/operations/development.md index ea7f10a6ba2dd..131b279fab683 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -265,8 +265,9 @@ options. Native macOS and Windows desktop artifact builds generate server bytecode with the packaged Electron executable, after ASAR creation and before signing. Linux builds skip generation until -native packaging and relocated AppImage reuse have been validated. Cross-platform and -cross-architecture builds also skip generation explicitly. CLI, SEA and WSL runtimes keep their +native packaging and relocated AppImage reuse have been validated. macOS x64 builds on ARM64 +hosts run the packaged x64 Electron under Rosetta to generate the cache. Other cross-platform and +cross-architecture builds skip generation explicitly. CLI, SEA and WSL runtimes keep their existing behavior; only desktop backend imports covered by generation receive prepared bytecode. Worker-only modules compile normally. diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 1a23cf0246fb0..41146da1ed4c1 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -11,8 +11,9 @@ agent can use this endpoint to: - create one or more ordinary top-level T3 threads; - list a project's threads and incrementally read any thread; - rename threads, regenerate titles, and link or unlink pull requests; -- send or steer follow-up messages; and -- wait for or interrupt ordinary thread runs. +- send or steer follow-up messages; +- wait for or interrupt ordinary thread runs; and +- schedule recurring work that runs without an active turn. These are T3 orchestration operations, not provider-native sub-agent APIs. Delegated tasks always create a T3 child thread and run. The child receives @@ -196,7 +197,12 @@ provider selection model-visible without allowing a request that cannot run. ## Tool Surface -The server exposes eleven orchestration tools. +`OrchestratorToolkit` in +[`toolkits/orchestrator/tools.ts`](../../apps/server/src/mcp/toolkits/orchestrator/tools.ts) +is the authoritative list of orchestration tools. The sections below cover +delegation and thread tools. `t3_thread_launch` is documented with them but +registered by the project toolkit in +[`toolkits/project/tools.ts`](../../apps/server/src/mcp/toolkits/project/tools.ts). ### `orchestrator_capabilities` @@ -458,6 +464,22 @@ Without `runId`, it selects the newest interruptible run. A terminal run is returned unchanged, and a thread with no active provider turn returns `no_active_run`. +### Scheduled tasks + +`schedule_task`, `list_scheduled_tasks`, and `delete_scheduled_task` manage +recurring work in the app scheduler; `schedule_task` with a `scheduledTaskId` +updates an existing task. A schedule +runs its prompt on an interval, at a fixed time in the server's local time +zone, or on each qualifying webhook request, even when no turn is active. +Without a signature, anyone with the webhook URL can trigger a run. +Signature checks, filters, and dedupe can skip a webhook request. +`bindToCurrentThread` defaults to `true` only when the caller has a thread in +the task's project; otherwise it defaults to `false`. Bound runs post into +that thread; unbound runs each launch a fresh thread in the task's project. +Explicit `bindToCurrentThread: true` without a caller thread in that project +is rejected. Pausing uses `schedule_task` with `scheduledTaskId` and `enabled: false`; +deletion is permanent. + ## Delegated Task Lifecycle The MCP server is a command ingress into V2. It does not call provider adapters @@ -493,8 +515,12 @@ Users rarely watch child threads, so a child question would otherwise go unanswered. When a child opens a pending `user_input` request, the parent gets a notification message telling it to answer with the pending request tools or ask the user. The message says whether the child is blocked or, for a Codex -async question, still working. If the question is answered or dismissed before -the notification starts a turn, that queued turn is cancelled. Approvals are not +async question, still working. If the parent's turn is running and its provider +can steer without interrupting tools, the notification steers into that turn, +like a background task's completion. A queued notice would wait behind a +long-running orchestrator turn that may never end. Otherwise it queues. If the +question is answered or dismissed before a queued notification starts a turn, +that queued turn is cancelled. Approvals are not routed, because those tools cannot answer them. A parent still blocked in a `delegate_task` wait gets the blocking question through that wait instead. diff --git a/docs/user/background-service.md b/docs/user/background-service.md index a2dedb1bc6e10..83974146c2cdf 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -32,9 +32,7 @@ before updating; to match a remote client's version, follow [Updating T3 Code](./updating.md). Pass an exact version (`t3 update 0.0.42`) to pin one, `--channel nightly` to -switch trains, or `--allow-downgrade` to move backwards. After one-day history -cleanup has pruned events, do not downgrade to a version from before that -cleanup. An older version may show incomplete conversation history. `preview` is a +switch trains, or `--allow-downgrade` to move backwards. `preview` is a maintainers' test train: its builds can be broken and are never offered as updates, so the installer and `t3 update` ask for confirmation before installing one. @@ -163,9 +161,8 @@ For failures after signing in to T3 Connect, see ## Reclaim database space -T3 Code keeps your conversations and removes internal event history it no -longer needs, such as streaming updates older than a day, while it runs. -Databases created before this shrank automatically +T3 Code removes internal history older than a day on its own while it runs; +your conversations are kept. Databases created before this shrank automatically still hold the freed space on disk until a one-time offline repair returns it and turns on automatic shrinking. Settings > Maintenance (web and desktop) or Settings > Maintenance (mobile) suggests the repair when at least 1 GiB can be diff --git a/docs/user/composer.md b/docs/user/composer.md index a3e06c9aed6f7..0008e15fce27a 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -177,28 +177,36 @@ Stashes containing uploaded files must be restored in their original environment Those files are retained for 24 hours. After an upload expires, restore the prompt and use **Attach again** or remove the missing file before sending. -## Voice input on iPhone - -On supported iPhones with iOS 26 or later, use the composer's microphone to record, -then confirm to transcribe. Text is inserted where your selection was when -recording started, ready for you to review and edit before sending. - -Voice input depends on Apple's on-device speech transcription. On a device without -it, the composer has no microphone button and **Settings → Microphone** says voice -input isn't available. - -The first use may download Apple's speech model and needs a network connection. -Later transcription works offline for that language. Recordings can be up to five -minutes long. Canceling, leaving the screen, or an audio interruption discards the -recording and preserves your existing draft. While recording, the screen stays +## Voice input on mobile + +On supported iPhones with iOS 26 or later and Android devices with Android 12 or +later, use the composer's microphone to record, then confirm to transcribe. Text is +inserted where your selection was when recording started, ready for you to review +and edit before sending. + +Voice input uses on-device speech transcription: Apple's on iOS, and compatible +Google speech services on Android. When it is unavailable for the device or its +language, the composer has no microphone button and **Settings → Microphone** says +voice input isn't available. On Android, keep T3 Code open while recording and +transcribing. + +The first use may download a speech model and needs a network connection. +Later transcription works offline for that language. On Android, transcription +processes the full recording at roughly its original duration. Recordings can be up to five +minutes long. Canceling or an audio interruption discards the recording and +preserves your existing draft. Navigating to another screen keeps dictation active; +use the floating controls to finish or cancel. While recording, the screen stays awake; it can sleep normally once recording stops. -Voice input records from the first connected microphone in **Settings → Microphone**. -The list mixes kinds (built-in, other wired, other Bluetooth, CarPlay) with wired and -Bluetooth devices you have dictated with, listed by name. Drag to rank them together: +Choose a preferred order in **Settings → Microphone**. Voice input requests the first +connected microphone in the list. The list mixes kinds (built-in, other wired, other +Bluetooth, and CarPlay on iOS) with wired and Bluetooth devices you have dictated +with, listed by name. Drag to rank them together: for example, put **Other Bluetooth** above your AirPods so a new headset is preferred while the AirPods microphone stays low. By default, CarPlay comes after the built-in -microphone. Swipe a device left to forget it. +microphone on iOS. Swipe a device left to forget it. Android may use another input +when the preferred microphone cannot be routed. Devices with the same name may share +a saved preference, but simultaneous same-name inputs remain separately selectable. Transcription runs on your device. T3 Code deletes the temporary audio after transcription or cancellation; only the message text is sent when you submit. diff --git a/docs/user/install.md b/docs/user/install.md index eb01607a91782..fde265746004e 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -164,6 +164,7 @@ computer. | Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | | Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | | Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | +| Kiro | Install [Kiro CLI](https://kiro.dev/docs/cli/), then run `kiro-cli login`. | | OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | | Antigravity | Install and sign in with Google from T3 Code's provider settings. | | Pi | Install [Pi](./providers-pi.md#set-up-pi) with Node.js 22.19 or newer, then run `pi` and `/login`. | @@ -198,8 +199,8 @@ their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), -[Antigravity](./providers-antigravity.md), [Pi](./providers-pi.md), -[Muse Code](./providers-muse.md), and [fx](./providers-fx.md). +[Antigravity](./providers-antigravity.md), [Kiro](./providers-kiro.md), +[Pi](./providers-pi.md), [Muse Code](./providers-muse.md), and [fx](./providers-fx.md). ## Next steps diff --git a/docs/user/keyboard-focus.md b/docs/user/keyboard-focus.md index b51262b6d69bf..8eb80a012f669 100644 --- a/docs/user/keyboard-focus.md +++ b/docs/user/keyboard-focus.md @@ -26,9 +26,9 @@ can be remapped in Settings > Keybindings. ## Browse threads -Tab enters the sidebar once. Up and Down move between threads without opening them; Enter -opens the focused thread or toggles a group. Right and Left expand and collapse groups. -Tab moves from the focused thread to its buttons. `F2` renames the focused thread. +Tab moves through each thread and its buttons. Up and Down also move between threads +without opening them; Enter opens the focused thread or toggles a group. Right and Left +expand and collapse groups. `F2` renames the focused thread. Press `Shift+F10` or the Context Menu key to open the focused thread's menu. Use the arrow keys to move through it, Enter to choose, and Escape to close it. diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index e1ba501e6fa3d..6017444e52a9f 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -46,11 +46,11 @@ Every user on the machine can read the policy, so do not put credentials in it. Use one of these per machine: -| OS | Native policy | Policy file | -| ------- | ------------------------------------------------------------------- | ------------------------------------------------- | -| macOS | Configuration profile for `com.t3tools.t3code` | `/Library/Application Support/T3Code/policy.json` | -| Windows | `Policy` string value under `HKLM\SOFTWARE\Policies\T3Tools\T3Code` | `C:\Program Files\T3Code\policy.json` | -| Linux | | `/etc/t3code/policy.json` | +| OS | Native policy | Policy file | +| ------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| macOS | Configuration profile for `com.t3tools.t3code` | `/Library/Application Support/T3Code/policy.json` | +| Windows | Group Policy template, or a `Policy` JSON value (both under `HKLM\SOFTWARE\Policies\T3Tools\T3Code`) | `C:\Program Files\T3Code\policy.json` | +| Linux | | `/etc/t3code/policy.json` | T3 Code does not merge sources. If a machine has both a native policy and a policy file, T3 Code refuses to start until you remove one. @@ -62,14 +62,28 @@ administrators. `com.t3tools.t3code`. The payload's keys are the policy's top-level keys (`schemaVersion`, `organization`, `supportUrl`, `settings`, `limits`); JSON objects become dictionaries and lists become arrays. To make the payload from a policy file you checked, run -`plutil -convert xml1 -o com.t3tools.t3code.plist policy.json` and upload the result. Install it as +`plutil -convert xml1 -o com.t3tools.t3code.plist policy.json` and upload the result, or run +`t3 policy templates --policy policy.json` to write a complete +`macos/t3code.mobileconfig`. Install it as a device profile, since T3 Code ignores user profiles, and deploy only one profile for this domain. A profile cannot hold `null`, so use a policy file if a setting's value must be `null`. -**Windows registry.** Set the `Policy` value (`REG_SZ`) to the whole policy as one line of JSON, +**Windows Group Policy.** Run `t3 policy templates ` and copy +`windows/t3code.admx` and `windows/en-US/t3code.adml` into your `PolicyDefinitions` folder or +central store, or import them into Intune. The policies appear under Computer Configuration > +Administrative Templates > T3 Code. Set **Organization** whenever you set any other T3 Code policy. +Locks and provider fields go in the two JSON policies, as the same objects a policy file would hold. +Machines pick up a change at the next Group Policy refresh; run `gpupdate /target:computer /force` +to apply it now, then restart T3 Code. +Setting **Allow T3 Connect**, **Allow webhook tasks**, **Allow importing browser cookies**, or +**Allow usage data** to **Disabled** turns that feature off. Setting any other T3 Code policy to +**Disabled** restricts nothing, the same as **Not Configured**. + +**Windows registry value.** Instead of the template, set the `Policy` value (`REG_SZ`) to the whole policy as one line of JSON, from Group Policy Preferences or an Intune script. T3 Code reads it through the Windows registry -API, so it works where registry tools such as `reg.exe` are blocked for users. In PowerShell, as an -administrator: +API, so it works where registry tools such as `reg.exe` are blocked for users. Use one or the other: +T3 Code refuses to start when the key holds both a `Policy` value and the template's settings. In +PowerShell, as an administrator: ```powershell $json = (Get-Content -Raw -Encoding UTF8 policy.json) -replace '\r?\n\s*', '' @@ -92,7 +106,8 @@ reopen the desktop app, or run `t3 service restart` for the [background service](./background-service.md). Reconnecting a phone or browser does not reload the policy. -To remove a policy, delete the file, remove the profile, or delete the registry value, then restart +To remove a policy, delete the file, remove the profile, set the Group Policy settings to +**Not Configured**, or delete the registry value, then restart the server. Users get back the settings they had chosen, since T3 Code never writes policy values into them. @@ -104,7 +119,9 @@ version does not support, or a machine with both a native policy and a file. The the policy came from and what is wrong. `t3 policy check` catches the same problems in a JSON file. For a profile or registry value, check -the JSON before you convert it. A policy written for a newer version can use keys an older one +the JSON before you convert it. For the Group Policy template, check a complete policy file that +holds the same settings, then paste its `settings` and `limits.providers.instances` objects into the +two JSON policies. A policy written for a newer version can use keys an older one cannot enforce, so upgrade T3 Code before you deploy a policy that uses them. ## Settings @@ -178,8 +195,13 @@ config home, and the environment variables set on each provider. They come from [`providers.instances`](#providers) or T3 Code's defaults, and users cannot change them, because a wrapper script, a launch flag, or a variable such as `NODE_OPTIONS` could otherwise turn off the provider's approvals. Pin the variables your users need, such as a gateway URL, in -`providers.instances`. Variables in the environment that starts T3 Code itself still reach -providers. +`providers.instances`. + +Variables in the environment that starts T3 Code itself still reach providers, and a default +binary path such as `claude` is looked up on that environment's `PATH`. The desktop app builds that +environment from settings the user controls, such as their shell profile, so a user could put their +own `claude` earlier on their `PATH`. Pin an absolute `binaryPath` in `providers.instances` for each +provider you allow. ACP agents' own mode menus have no effect under `maxRuntimeMode`, except Antigravity's, whose modes T3 Code can place under the limit. Users still choose which ACP Registry agent runs; leave @@ -232,7 +254,8 @@ so restart T3 Code after that. `providers.allowedDrivers` lists the providers that may run, by ID: `codex` (Codex), `claudeAgent` (Claude), `cursor` (Cursor), `grok` (Grok), `opencode` (OpenCode), `antigravity` -(Antigravity), `fx` (fx), `pi` (Pi), `muse` (Muse Code), and `acpRegistry` (ACP Registry). +(Antigravity), `fx` (fx), `kiro` (Kiro), `pi` (Pi), `muse` (Muse Code), and `acpRegistry` (ACP +Registry). `t3 policy check` rejects an ID this version does not know. Threads on other providers stay, marked unavailable, and users can still turn those providers off. @@ -249,9 +272,13 @@ starts the binary you set. - `mode`: `auto` keeps the normal update flow. `off` stops T3 Code from checking for updates, for machines that get T3 Code through device management. -- `feedUrl`: the desktop app updates from your own mirror of the release feed instead of GitHub. - Publish only the versions you have approved. The `t3` command-line server updates through npm, - so pin it with your package tooling. +- `feedUrl`: T3 Code updates from your own mirror instead of GitHub: the desktop app, `t3 update`, + and updates started from another device. Publish only the versions you have approved, laid out + as on a GitHub release. The release's update files (`latest.yml`, `latest-mac.yml`, + `latest-linux.yml`, `latest-linux-arm64.yml`, and their `nightly` versions if you allow nightly) + and the desktop installers they list go at the URL's root. The release's `t3-*` archives and + `SHA256SUMS` go under `v/`. A `t3` run through `npx` comes from npm, so pin it with your + package tooling. - `allowNightly: false`: the nightly channel is unavailable. ## What a policy does not cover diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 5ec8dc48fa4c2..796e3f316122d 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -54,4 +54,7 @@ directly to the agent that asked, including a subagent. Other pending requests s Antigravity can still send native approval requests in **Full access**. It only offers remembered approvals for actions that support them. +Kiro offers only **Supervised** and **Full access**. In **Supervised** Kiro also asks you to +review a turn's file changes. See [Kiro permissions](./providers-kiro.md#permission-modes). + See the [provider guides](./install.md#providers) for setup and provider-specific limits. diff --git a/docs/user/providers-kiro.md b/docs/user/providers-kiro.md new file mode 100644 index 0000000000000..6b94ca14f2e5e --- /dev/null +++ b/docs/user/providers-kiro.md @@ -0,0 +1,60 @@ +# Kiro + +T3 Code runs the [Kiro CLI](https://kiro.dev/docs/cli/) as an agent, using your own Kiro account, +custom agents, steering, hooks, and MCP configuration. + +## Set Up Kiro + +1. Install the Kiro CLI on the machine running the T3 Code server. On macOS or Linux, run + `curl -fsSL https://cli.kiro.dev/install | bash`. On Windows 11, run + `irm 'https://cli.kiro.dev/install.ps1' | iex` in PowerShell. Kiro's + [installation guide](https://kiro.dev/docs/getting-started/installation/) lists other options. +2. Run `kiro-cli login` once in a terminal. Kiro Pro and higher plans can instead set an API key as + `KIRO_API_KEY` in the Kiro provider's environment variables. +3. Open **Settings → Providers**, enable Kiro, and refresh it. + +If `kiro-cli` is not on the server's `PATH`, set its **Binary path**. On macOS and Linux, the +installer usually puts it in `~/.local/bin`. T3 Code starts Kiro's CLI V3 engine, so a Kiro CLI +that cannot run V3 will not start. + +## Models + +The model picker lists the models your Kiro account can use once Kiro is signed in. New threads +start on your Kiro default model: `auto`, which lets Kiro choose, unless your account or +organization sets another. A model your account cannot use stops the turn with an error instead of +running on another model. + +Models with adjustable reasoning effort, such as recent Claude Opus and Sonnet models and GPT-5.6, +show a **Reasoning** setting with only the levels Kiro offers for that model. Your default model and +models without effort control run at Kiro's own setting. + +## Agents + +The **Agent** setting next to the model picks the +[Kiro agent](https://kiro.dev/docs/custom-agents/) a thread runs as: Kiro's built-in agents such as +Spec and Plan, and your own agents in `~/.kiro/agents`. Agents in a project's `.kiro/agents` are +not listed yet. Refresh the Kiro provider after adding or removing an agent. + +The thread's model still applies when an agent sets its own `model`. An agent that was removed +leaves the thread on Kiro's current agent. + +## Permission Modes + +Kiro asks before tool calls such as writes and commands, and T3 Code answers by the permission mode: + +- **Supervised** sends those requests to you. Kiro's Autopilot is off, so Kiro also asks you to + review the turn's file changes before it finishes. +- **Full access** approves them without asking you. Autopilot stays on, so there is no review step. + +Kiro offers no **Auto** or **Auto-accept edits**. Approvals offer only the choices Kiro sends. Kiro's +**Always allow** saves a rule for the whole workspace, so T3 Code shows it only when Kiro offers no +one-time choice. + +## Steering, Forks and Compaction + +- A message you send while Kiro is working joins the running turn. Kiro reads it once its current + step or tool call finishes. A message with images stops the turn and starts a new one with it. +- Forking a thread from a message gives the fork Kiro's own copy of the conversation up to that + message. +- `/compact` uses Kiro's own compaction. Kiro also compacts on its own as the conversation fills its + context. diff --git a/docs/user/t3-connect-troubleshooting.md b/docs/user/t3-connect-troubleshooting.md index 27af390b81ef8..4880ce52a2e0d 100644 --- a/docs/user/t3-connect-troubleshooting.md +++ b/docs/user/t3-connect-troubleshooting.md @@ -99,7 +99,8 @@ Fix: ## Sign-in, account, or link problems -T3 Connect does not have an active link between your account and the host. +This device's T3 Connect sign-in is not ready yet, or T3 Connect does not have +an active link between your account and the host. - **The host is linked to a different account.** Sign in to the same T3 Connect account on this device and on the host. On the host, run `t3 connect logout`, @@ -107,10 +108,14 @@ T3 Connect does not have an active link between your account and the host. - **T3 Connect is off on the host, or the host only publishes agent activity.** On the host, open **Settings > Sharing & access** and turn **T3 Connect** on, or run `t3 connect`. -- **The host was linked, but the server did not apply the link yet.** Run - `t3 service restart` on the host, or restart `t3 serve`. +- **The host was linked, but the server did not apply the link yet.** Restart + the server on the host: quit and reopen the T3 Code desktop app, run + `t3 service restart`, or stop `t3 serve` and run it again. - **This device's sign-in expired, or the host no longer accepts this device.** Sign out of T3 Connect and sign in again. +- **This device is still loading its T3 Connect sign-in.** Wait a moment; + T3 Code retries on its own. If it keeps waiting, reload or restart T3 Code on + this device. - **Your account reached its tunnel limit.** Remove an environment you do not use from your T3 account's T3 Connect page, or [link a paid T3 Chat account](./remote-access.md#more-machines-with-t3-chat) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index bed4ed73a40c5..37f679725f81b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -5,8 +5,11 @@ need a separate branch and working directory. ## Start a thread -On web and desktop, a new thread keeps the current project and carries your model -and mode selections, unless the destination project has its own model default. +On web and desktop, **New thread** opens the project picker when you have multiple +projects in the default or project-grouped sidebar. The legacy sidebar always +starts in the current project. **Shift-click** to start in +the current project, or use a project's own new-thread action. A new thread carries +your model and mode selections, unless the destination project has its own model default. Its branch and workspace mode come from your configured defaults. To continue in an existing worktree, use **New thread in this worktree** from the branch toolbar. diff --git a/docs/user/usage.md b/docs/user/usage.md index 71eb1e0fe8773..d25bdba81f00a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -24,12 +24,13 @@ OpenCode reads its SQLite database and older JSON history. Antigravity reads loc databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the server to read a different data directory; comma-separated paths read multiple directories. -Cursor reads account usage from Cursor's dashboard API using the CLI login saved on the server. +Cursor reads account usage from Cursor's API for the account you signed in with in T3 or set as +`CURSOR_API_KEY`, or otherwise the CLI login saved on the server. This includes headless T3 sessions and desktop usage across machines; the same account counts -once across connected environments. Without an accessible CLI login, T3 shows a +once across connected environments. Without any of these, T3 shows a notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. -On macOS, choose **Enable Cursor usage** on Usage to allow T3 to read your existing CLI login -from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask +On macOS without a T3 sign-in, choose **Enable Cursor usage** on Usage to allow T3 to read your +existing CLI login from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask you to allow access on the server Mac. Usage includes each configured account's history, including disabled accounts. Custom homes follow @@ -159,10 +160,11 @@ belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode in OpenCode with an API key. A Console browser sign-in alone does not report them. You can also add your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's **Environment variables**, then refresh provider status. Cursor reports -its monthly allowance, including separate Auto and API usage, using the CLI login or -`CURSOR_AUTH_TOKEN`. On macOS, this includes the default Keychain login after you enable Cursor -usage. Keychain login is used for limits only with Cursor's default API endpoint. If you configure -a custom Cursor endpoint, use an explicit token or file-based CLI login for limits. +its monthly allowance, including separate Auto and API usage, for `CURSOR_AUTH_TOKEN` when it is set, +otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`, otherwise for the CLI +login. On macOS, this includes the default Keychain login after you enable Cursor usage. Sign-in, API key, +and Keychain login report limits only with Cursor's default API endpoint. If you configure a custom +Cursor endpoint, use an explicit token or file-based CLI login for limits. Grok reports the remaining subscription allowance and reset time for its current billing period after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication diff --git a/evals/mcp-tool-use/README.md b/evals/mcp-tool-use/README.md index 3b817f574455f..fadea6438da46 100644 --- a/evals/mcp-tool-use/README.md +++ b/evals/mcp-tool-use/README.md @@ -26,6 +26,7 @@ Cases are JSON lines in `cases/`, one file per area. The case schema in `run.ts` - `kind`: `bad` reproduces a mistake, `guard` protects correct behavior, `control` is ordinary work. - `experience`: the real mistake, in a sentence. `source`: where it happened. - `setup`: the fake thread, its `world` (repo, branch, files, PRs, pages), `priorTurns`, and `mocks` for tool results. +- `setup.priorTurns[].toolCalls[].resultFile`: load a repository-relative file as the tool result instead of copying its contents into the case. An empty `prompt` resumes a top-level thread after its last prior tool result without adding a user message. Side-chat and subagent setups require a nonempty prompt. - `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `textMust`, and `linked`. Device cases also use `device` to check every child brief, forbid raw UI commands, bound failed device actions per user turn, and require artifact paths in completed final answers. Check that the case builds with `--dry-run --case `. Then run it on the text before your fix, where it must fail, and on the text after, where it must pass. diff --git a/evals/mcp-tool-use/cases/visual-preview.ndjson b/evals/mcp-tool-use/cases/visual-preview.ndjson index 1940692d9fe17..1b9d713307e91 100644 --- a/evals/mcp-tool-use/cases/visual-preview.ndjson +++ b/evals/mcp-tool-use/cases/visual-preview.ndjson @@ -28,3 +28,5 @@ {"id": "proactive-visual-ci-time-breakdown", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user shared CI job times over several runs and asked what eats the time. The agent answered in prose; a stacked chart would have shown it.", "setup": {"role": "top"}, "prompt": "CI got slow. Here are job times in minutes for the last 8 runs on main. What's eating the time?\n\nRun | install | typecheck | lint | test-server | test-web | build | e2e\n1 | 2.1 | 3.0 | 1.2 | 6.8 | 4.1 | 3.3 | 7.9\n2 | 2.0 | 3.1 | 1.1 | 6.9 | 4.0 | 3.4 | 8.2\n3 | 2.2 | 3.0 | 1.2 | 7.4 | 4.2 | 3.3 | 8.0\n4 | 2.1 | 3.2 | 1.3 | 9.8 | 4.1 | 3.5 | 8.1\n5 | 2.3 | 3.1 | 1.2 | 12.6 | 4.3 | 3.4 | 8.4\n6 | 2.2 | 3.3 | 1.2 | 13.1 | 4.2 | 3.6 | 12.9\n7 | 2.1 | 3.2 | 1.3 | 13.4 | 4.4 | 3.5 | 13.2\n8 | 2.2 | 3.2 | 1.2 | 13.0 | 4.3 | 3.4 | 13.5", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} {"id": "requested-after-video-for-edit", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "agent refuses requested browser recording of its own edit", "experience": "A user asked for an after video of a UI fix. The agent declined because the injected instructions prohibited preview for its own edits.", "rule": "Use preview when the user asks for browser work, including recording an edit.", "setup": {"role": "top", "context": "The UI fix is complete and a development server is already running at http://localhost:3000. Browser verification and recording are authorized.", "mocks": {"preview_status": {"available": true, "tabId": "tab_1", "url": "http://localhost:3000/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:3000/"}, "preview_recording_start": {"recording": true, "tabId": "tab_1", "startedAt": "2026-01-01T00:00:00.000Z"}, "preview_recording_stop": {"id": "recording_1", "tabId": "tab_1", "path": "/tmp/after.webm", "mimeType": "video/webm", "sizeBytes": 1024, "createdAt": "2026-01-01T00:00:01.000Z"}}}, "prompt": "Use the T3 browser to verify the UI fix you just made and record an after video.", "expect": {"calls": [{"tool": "preview_recording_start"}, {"tool": "preview_recording_stop"}], "order": ["preview_recording_start", "preview_recording_stop"]}, "source": {"report": "A user requested an after video of a UI fix; the agent declined because preview was prohibited for its own edits."}} {"id": "dev-server-opened-blank-then-navigated", "area": "visual-preview", "kind": "bad", "pattern": "preview_open on a blank tab, then preview_navigate to the dev-server port", "experience": "Asked to open a dev server in the T3 preview, agents opened a blank tab and navigated it to the port in a second call. 121 of these pairs in a month of maintainer threads.", "rule": "preview_open takes a url but not a dev-server target", "privacy": "commit-safe", "setup": {"role": "top", "context": "CLAUDE.md says: for t3code browser work, use the server-side preview_* mcp tools, with `target: {kind: \"environment-port\", port: ...}` for dev servers in the current environment. The dev server for this repo is running; `vp run dev` printed `Local: http://localhost:5173/`.", "mocks": {"preview_status": {"available": true, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true}}}}, "prompt": "open the dev server in the t3 preview, i want to click around", "expect": {"calls": [{"tool": "preview_open", "args": {"target.port": 5173}}], "maxCalls": {"preview_open": 1, "preview_navigate": 0}}, "maxSteps": 6, "source": {"thread": "Maintainer threads, 2026-10: preview_open with no url followed by preview_navigate to an environment-port target, 121 times"}} +{"id": "browser-status-reuses-existing-tab", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "pattern": "usable Browser tab needs no new tab", "experience": "An already paired, usable tab should be reused during verification.", "source": {"report": "Maintainer browser verification failure, 2026-11-03"}, "setup": {"role": "top", "context": "The UI fix is complete. The test server is healthy at http://localhost:5173 and its browser profile is already paired. The current tab can be automated.", "mocks": {"preview_status": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "tabs": [{"tabId": "tab_1", "url": "http://localhost:5173/", "owner": "agent", "ownedByCaller": true}], "control": {"owner": "agent", "ownedByCaller": true, "generation": 0}}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:5173/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:5173/", "screenshotPath": "/tmp/after.png"}}, "priorTurns": [{"role": "user", "text": "Use the Browser panel to verify the UI fix and capture an after screenshot."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "cat .agents/skills/test-t3-app/SKILL.md"}, "result": "", "resultFile": ".agents/skills/test-t3-app/SKILL.md"}]}, {"role": "assistant", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "tabs": [{"tabId": "tab_1", "url": "http://localhost:5173/", "owner": "agent", "ownedByCaller": true}], "control": {"owner": "agent", "ownedByCaller": true, "generation": 0}}}]}], "harness": "claude"}, "prompt": "", "expect": {"calls": [{"tool": "preview_snapshot"}], "callsMustNot": [{"tool": "preview_open", "args": {"reuseExistingTab": false}}]}, "maxSteps": 10, "notes": "Checks reuse of an existing usable tab. Screenshot saving is outside this control. Prior tool-result replay is validated on the Claude harness; Codex output wrapping needs separate validation."} +{"id": "browser-resume-after-unavailable-status", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "closed Browser panel treated as unavailable automation", "experience": "The agent read test-t3-app, saw available false with no tab, and asked the user to open the panel instead of calling preview_open. Opening a tab succeeded on the next turn.", "source": {"report": "Maintainer browser verification failure, 2026-11-03; fresh skill read followed by unavailable status. Opus replay reproduces the stop on the old skill."}, "setup": {"role": "top", "context": "Repository instructions: Before web or desktop UI verification, read .agents/skills/test-t3-app/SKILL.md and follow it. If a skill causes you to stop or leave requested work unfinished, link the SKILL.md, quote the relevant instruction, and explain how it applies.", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:5173/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:5173/", "screenshotPath": "/tmp/after.png"}}, "world": {"repo": "acme/app", "branch": "fix/permissions-popup", "status": "", "files": {"apps/web/src/components/ComposerContextPermissions.tsx": "export function ComposerContextPermissions() { return ; }\n"}, "description": "The requested fix is already committed as a1b2c3d on fix/permissions-popup, ahead of main. It changes ComposerContextPermissions.tsx to open the permissions popup above its trigger. Dependencies are installed. No development server is running and no .t3 directory exists.", "commits": [{"sha": "a1b2c3d", "subject": "fix(web): open composer permissions above its button"}]}, "priorTurns": [{"role": "user", "text": "When I click Full access in the composer, open its permissions menu directly above that button."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "git commit -am \"fix(web): open composer permissions above its button\""}, "result": "[fix/permissions-popup a1b2c3d] fix(web): open composer permissions above its button\n 1 file changed, 3 insertions(+), 1 deletion(-)"}]}, {"role": "assistant", "text": "Implemented. Clicking Full access opens the permissions menu above the button. Focused checks pass. Full-client visual verification remains undone."}, {"role": "user", "text": "before/after screenshot"}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "cat .agents/skills/test-t3-app/SKILL.md"}, "result": "", "resultFile": ".agents/skills/test-t3-app/SKILL.md"}]}, {"role": "assistant", "text": "I'll use the test-t3-app skill to capture the before and after states in the app."}, {"role": "assistant", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}}]}], "shellMocks": [{"match": "git status|ls .*\\.t3", "output": "ls: .t3: No such file or directory"}], "harness": "claude"}, "prompt": "", "expect": {"calls": [{"tool": "preview_open"}]}, "maxSteps": 8, "notes": "Resume at the failed decision after reading the skill and receiving available=false. Opus reproduces the premature stop; Sonnet did not reliably reproduce it. This checks recovery through preview_open, not completion of screenshots. Prior tool-result replay is validated on the Claude harness; Codex output wrapping needs separate validation."} 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 ff65511a9cc7d..6c24f18ac5063 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"; @@ -392,6 +394,28 @@ it("replays earlier capability results with the model under test as the caller", .flatMap((message) => message.content) .find((block) => block.type === "tool_result" && String(block.content).includes("inherited")); assert.include(String(replayed?.content), '"inheritedModel":"claude-opus-5-5"'); + const resumed = buildMessages(makeSample({ ...testCase, prompt: "" }, base)); + assert.equal(resumed.at(-1)?.role, "user"); + assert.equal(resumed.at(-1)?.content.at(-1)?.type, "tool_result"); + for (const setup of [ + { + ...testCase.setup, + sideChat: { parentThreadId: "thread-parent", digest: ["The permissions fix is committed."] }, + }, + { ...testCase.setup, role: "native-subagent" as const }, + { ...testCase.setup, role: "delegated-child" as const }, + ]) { + assert.throws( + () => + buildMessages( + makeSample( + { ...testCase, prompt: "", setup }, + { ...base, subagentContext: "Hook context" }, + ), + ), + /Empty-prompt replay/, + ); + } }); it("gives each launched thread its own id, and reading it back shows that thread's brief", () => { @@ -564,7 +588,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([ @@ -1278,7 +1302,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", () => { @@ -2936,7 +2960,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([ @@ -3110,7 +3134,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( @@ -3145,7 +3169,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 01a3bd9a1de11..02c5d8ce58e46 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -10,6 +10,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, @@ -139,6 +140,8 @@ const PriorToolCall = Schema.Struct({ input: Schema.Unknown, /** For a device tool, `"default"` runs the call on the world's devices for T3's result. */ result: Schema.Unknown, + /** Repository-relative file whose current contents supply this tool result. */ + resultFile: Schema.optional(Schema.String), }); /** @@ -274,6 +277,7 @@ export const McpToolUseCase = Schema.Struct({ Schema.Struct({ parentThreadId: Schema.String, digest: Schema.Array(Schema.String) }), ), }), + /** Empty to resume a top-level thread without side-chat context after its last tool result. */ prompt: Schema.String, /** Records for the context chips `prompt` links, projected as T3 sends them. */ promptContext: Schema.optional(Schema.Struct({ records: Schema.Array(ComposerContextRecord) })), @@ -879,6 +883,19 @@ export const buildMessages = (sample: Sample) => { else messages[messages.length - 1] = { role, content: [...last.content, ...blocks] }; }; const { role, context, delegateRole, priorTurns = [] } = testCase.setup; + const sideChat = testCase.setup.sideChat; + if (testCase.prompt === "" && (role !== "top" || sideChat !== undefined)) { + throw new Error( + `${testCase.id}: Empty-prompt replay requires a top-level thread without side-chat context.`, + ); + } + const sideChatContext = + sideChat === undefined + ? undefined + : makeSideChatContext({ + parentThreadId: ThreadId.make(sideChat.parentThreadId), + digest: sideChat.digest, + }); const native = role === "native-subagent"; const notes = [ native @@ -925,19 +942,11 @@ export const buildMessages = (sample: Sample) => { testCase.promptContext === undefined ? testCase.prompt : projectComposerContextForProvider({ text: testCase.prompt, ...testCase.promptContext }); - const sideChat = testCase.setup.sideChat; const prompt = - sideChat === undefined - ? userText - : withInlineContext( - makeSideChatContext({ - parentThreadId: ThreadId.make(sideChat.parentThreadId), - digest: sideChat.digest, - }), - userText, - ); + sideChatContext === undefined ? userText : withInlineContext(sideChatContext, userText); const childPrompt = () => taskPrompt({ task: prompt, role: delegateRole }); - push("user", text(role === "delegated-child" ? childPrompt() : prompt)); + if (testCase.prompt !== "") + push("user", text(role === "delegated-child" ? childPrompt() : prompt)); // Claude Code gives a subagent its SubagentStart hooks' context as a reminder after the task. if (native && base.subagentContext) { push("user", reminder(`SubagentStart hook additional context: ${base.subagentContext}`)); @@ -2124,12 +2133,35 @@ const main = Effect.gen(function* () { const wanted = flags.case?.flatMap((ids) => ids.split(",")); const codex = isCodex(flags.model); const harness = codex ? "codex" : "claude"; - const selected = cases.filter( + const selectedCases = cases.filter( ({ id, setup }) => (wanted === undefined || wanted.includes(id)) && (setup.harness === undefined || setup.harness === harness), ); - if (selected.length === 0) return yield* Effect.die(new Error("No case matches --case.")); + if (selectedCases.length === 0) return yield* Effect.die(new Error("No case matches --case.")); + const selected = yield* Effect.forEach(selectedCases, (testCase) => + Effect.gen(function* () { + const priorTurns = yield* Effect.forEach(testCase.setup.priorTurns ?? [], (turn) => + Effect.gen(function* () { + const toolCalls = yield* Effect.forEach(turn.toolCalls ?? [], (call) => + call.resultFile === undefined + ? Effect.succeed(call) + : fs + .readFileString(path.resolve(import.meta.dirname, "../..", call.resultFile)) + .pipe(Effect.map((result) => ({ ...call, result }))), + ); + return { ...turn, toolCalls }; + }), + ); + return { + ...testCase, + setup: { + ...testCase.setup, + priorTurns, + }, + }; + }), + ); // Trimmed captures without user data ship beside the cases. `--template` also takes a raw // CLIProxyAPI capture. const templatePath = @@ -2326,7 +2358,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 e7289c39147dc..1190e54c57b6e 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 b2912d680c855..7b7166990e616 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,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 d1b48e1d76f4f..6fd4550d93f31 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -532,6 +532,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/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts index 3f9dc8f992454..239aed398d77a 100644 --- a/packages/client-runtime/src/connection/compatibility.test.ts +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -31,6 +31,7 @@ describe("orchestration protocol compatibility", () => { expect(socketUrl.searchParams.get("orchestrationProtocol")).toBe( String(ORCHESTRATION_PROTOCOL_VERSION), ); + expect(socketUrl.searchParams.get("clientCapabilities")).toBe("settingsSecretReferences"); expect(socketUrl.searchParams.get("wsTicket")).toBe("secret"); expect(socketUrl.searchParams.get("connectionMethod")).toBe("relay"); }); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts index 3ca2e226db954..fccc68bff751e 100644 --- a/packages/client-runtime/src/connection/compatibility.ts +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -1,6 +1,8 @@ import { + CLIENT_CAPABILITIES_QUERY_PARAM, ORCHESTRATION_PROTOCOL_QUERY_PARAM, ORCHESTRATION_PROTOCOL_VERSION, + SETTINGS_SECRET_REFERENCES_CAPABILITY, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -37,5 +39,6 @@ function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean { export function appendOrchestrationProtocol(socketUrl: string): string { const url = new URL(socketUrl); url.searchParams.set(ORCHESTRATION_PROTOCOL_QUERY_PARAM, String(ORCHESTRATION_PROTOCOL_VERSION)); + url.searchParams.set(CLIENT_CAPABILITIES_QUERY_PARAM, SETTINGS_SECRET_REFERENCES_CAPABILITY); return url.toString(); } diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 5d396888eb593..93cc54bd044d0 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -8,6 +8,8 @@ import { presentConnectionDiagnosis } from "./diagnosis.ts"; import { mapManagedRelayError, mapRemoteEnvironmentError } from "./errors.ts"; import { AVAILABLE_CONNECTION_STATE, + ConnectionBlockedError, + ConnectionTransientError, type ConnectionAttemptError, type SupervisorConnectionState, } from "./model.ts"; @@ -128,6 +130,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 a3de100766a7c..4272943e0daa9 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. @@ -329,7 +357,10 @@ export function presentConnectionDiagnosis( hops, steps: steps(host, located, failure), 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/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index b70b2ef17615f..82a2aafa00140 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -202,7 +202,7 @@ describe("ConnectionResolver", () => { label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", + "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2&clientCapabilities=settingsSecretReferences", httpAuthorization: null, target, }); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index eede2763291e3..b4fc6955366a0 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -324,7 +324,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)); @@ -346,6 +346,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({ @@ -763,6 +811,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(); @@ -971,7 +1042,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)); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 3c969f0206243..5f279edf568c4 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -288,9 +288,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(); @@ -570,6 +575,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) { @@ -577,8 +585,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": @@ -1169,9 +1183,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( diff --git a/packages/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index 69e48f5949a9b..2a437e3d12815 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: { @@ -272,6 +269,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 eb749473dbbf8..821a92721d357 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({ @@ -153,7 +150,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", () => @@ -169,7 +166,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", () => @@ -213,7 +210,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", () => @@ -257,7 +254,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", () => @@ -293,7 +290,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", () => @@ -347,7 +344,7 @@ describe("V2 environment commands", () => { watching: false, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect.each(["waiting", "completed", "rolled_back"] as const)( @@ -418,7 +415,7 @@ describe("V2 environment commands", () => { }, ], ); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect( @@ -472,7 +469,7 @@ describe("V2 environment commands", () => { subagentId, }, ]); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect( @@ -531,7 +528,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", () => @@ -559,6 +556,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/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index b7a3b3403bd48..e2334489302fb 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", () => { @@ -229,3 +236,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/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index adfad46a41b89..f2ef2c5d02185 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -67,6 +67,40 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires the destination read grant to refresh providers", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const refresh = createCommandPermissions(runtime, WS_METHODS.serverRefreshProviders); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(refresh.permissionAtom(env))).toBe(false); + expect((yield* refresh.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthOrchestrationReadScope], + permissions: [AuthOrchestrationReadScope], + }), + ); + expect(registry.get(refresh.permissionAtom(env))).toBe(true); + yield* refresh.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(refresh.permissionAtom(other))).toBe(false); + expect((yield* refresh.authorize(registry, other).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set(sessions(env), AsyncResult.success(grant(false))); + expect(registry.get(refresh.permissionAtom(env))).toBe(false); + expect((yield* refresh.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + }), + ), + ); + it.effect("requires the destination preview grant to close a browser tab", () => Effect.scoped( Effect.gen(function* () { 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/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/projectRemoval.ts b/packages/client-runtime/src/state/projectRemoval.ts index 09c65a772e689..2225e5758bd2d 100644 --- a/packages/client-runtime/src/state/projectRemoval.ts +++ b/packages/client-runtime/src/state/projectRemoval.ts @@ -1,7 +1,9 @@ +import { isListedThreadLineage } from "@t3tools/contracts"; + import type { EnvironmentProject, EnvironmentThreadShell } from "./models.ts"; export function planProjectRemoval< - Thread extends Pick, + Thread extends Pick, >(options: { readonly members: ReadonlyArray< Pick & { @@ -20,7 +22,10 @@ export function planProjectRemoval< const projectThreads = threads.filter((thread) => memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); - const threadCount = projectThreads.length + (options.unloadedThreadCount ?? 0); + // Side chats and subagents go with their parent but never show in thread lists. + const threadCount = + projectThreads.filter((thread) => isListedThreadLineage(thread.lineage)).length + + (options.unloadedThreadCount ?? 0); const isWholeGroup = members.length === groupMemberCount; const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index 7af35dd95a4ff..a013699e43e52 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -34,6 +34,10 @@ export function createSourceControlEnvironmentAtoms( label: "environment-data:source-control:check-starred", tag: WS_METHODS.sourceControlStarred, }), + star: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:star", + tag: WS_METHODS.sourceControlStar, + }), repository: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:source-control:repository", tag: WS_METHODS.sourceControlLookupRepository, 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/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 ba5e119438290..2669416db42fe 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -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/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index da62615e890f6..cdbda23422237 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, @@ -154,6 +156,98 @@ 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, + } 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 +317,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, @@ -278,6 +374,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/voice-input/controller.test.ts b/packages/client-runtime/src/voice-input/controller.test.ts index d433748e5bdbb..4b7f9a9c3d40d 100644 --- a/packages/client-runtime/src/voice-input/controller.test.ts +++ b/packages/client-runtime/src/voice-input/controller.test.ts @@ -9,7 +9,11 @@ import { type VoiceInputControllerDependencies, type VoiceRecorder, } from "./controller.ts"; -import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts"; +import { + VoiceTranscriptionError, + type PreparedVoiceTranscription, + type VoiceTranscriber, +} from "./transcription.ts"; function deferred() { let resolve!: (value: T) => void; @@ -116,6 +120,21 @@ describe("VoiceInputController", () => { expect(unsupported.controller.currentState.error).toContain("not available"); expect(unsupported.recorder.record).not.toHaveBeenCalled(); + const reason = new VoiceTranscriptionError( + "unavailable", + "This device requires iOS 26 or later.", + ); + const unavailable = createHarness({ + getTranscriber: () => ({ + prepare: async () => { + throw reason; + }, + }), + }); + await unavailable.controller.start(); + expect(unavailable.controller.currentState.error).toBe(reason.message); + expect(unavailable.recorder.record).not.toHaveBeenCalled(); + const denied = createHarness({ requestPermission: async () => ({ granted: false, canAskAgain: false }), }); diff --git a/packages/client-runtime/src/voice-input/controller.ts b/packages/client-runtime/src/voice-input/controller.ts index b9d0f54f67672..41576db90cf8e 100644 --- a/packages/client-runtime/src/voice-input/controller.ts +++ b/packages/client-runtime/src/voice-input/controller.ts @@ -1,6 +1,10 @@ import { replaceTextRange } from "@t3tools/shared/composerTrigger"; -import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts"; +import { + VoiceTranscriptionError, + type PreparedVoiceTranscription, + type VoiceTranscriber, +} from "./transcription.ts"; export const VOICE_RECORDING_LIMIT_SECONDS = 5 * 60; @@ -34,7 +38,7 @@ export type VoiceRecorderStatus = { export interface VoiceRecorder { readonly uri: string | null; - prepareToRecordAsync(): Promise; + prepareToRecordAsync(signal?: AbortSignal): Promise; record(options: { readonly forDuration: number }): void; stop(): Promise; } @@ -167,14 +171,18 @@ function preparationErrorMessage(error: unknown): string { if (errorCode(error) === "unsupported-locale") { return "Voice transcription is not available for this language."; } - return "Could not prepare voice transcription."; + return error instanceof VoiceTranscriptionError + ? error.message + : "Could not prepare voice transcription."; } function transcriptionErrorMessage(error: unknown): string { if (error instanceof Error && error.message === "voice-operation-busy") { return "Voice transcription is still finishing. Try again shortly."; } - return "Could not transcribe this recording."; + return error instanceof VoiceTranscriptionError + ? error.message + : "Could not transcribe this recording."; } const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; @@ -258,7 +266,7 @@ export class VoiceInputController { await this.dependencies.configureRecording(); this.recordingConfigured = true; if (!this.isCurrent(operationToken)) return; - await this.dependencies.recorder.prepareToRecordAsync(); + await this.dependencies.recorder.prepareToRecordAsync(abortController.signal); if (!this.isCurrent(operationToken)) return; this.recordingUri = this.dependencies.recorder.uri; this.rememberRecordingUri(this.recordingUri); @@ -441,12 +449,7 @@ export class VoiceInputController { ? { phase: "error", error, errorAction: "retry" } : { phase: "idle", error: null, errorAction: null }, ); - try { - await this.dependencies.recorder.stop(); - } catch { - } finally { - await this.releaseResources(); - } + await this.releaseResources(); } private async releaseResources(): Promise { @@ -454,6 +457,11 @@ export class VoiceInputController { try { this.rememberRecordingUri(this.dependencies.recorder.uri); } catch {} + if (this.recordingConfigured) { + try { + await this.dependencies.recorder.stop(); + } catch {} + } this.recordingUri = null; for (const uri of this.ownedRecordingUris) { try { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index d3139f2eeff24..91db78715551e 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -20,6 +20,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, + [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, @@ -36,6 +37,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.projectsCreateFromThread]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, + [WS_METHODS.sourceControlStar]: AuthSourceControlWriteScope, // Signing in replaces the server's GitHub credential, as saving a token in Settings does. [WS_METHODS.sourceControlSignInStart]: AuthSettingsWriteScope, [WS_METHODS.sourceControlSignInWait]: AuthSettingsWriteScope, diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index a77e14b555420..6bec048a56b4e 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -4,6 +4,8 @@ import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts // A supervisor must not restart a server refused by the state-directory owner. export const SERVER_EXIT_CODE_STATE_DIR_OWNED = 78; +// A server the managed policy refused, so the desktop app blames the policy rather than the release. +export const SERVER_EXIT_CODE_MANAGED_POLICY = 77; export const DesktopBackendBootstrap = Schema.Struct({ mode: Schema.Literal("desktop"), diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 24b72dbe26d7a..417d1c81b418e 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; export const DevicePlatform = Schema.Literals(["ios", "android"]); export type DevicePlatform = typeof DevicePlatform.Type; @@ -40,11 +41,11 @@ export const EasDeviceHostConfig = Schema.Struct({ /** EAS project that sessions run under, when the project's app config does not name one. */ appId: Schema.optional(TrimmedNonEmptyString), /** - * Expo access token, kept in the secret store. When empty the server uses - * EXPO_TOKEN, then the `eas login` session. This is `secretSetting` from - * settings.ts, which imports this module. + * Expo access token, kept in the secret store, or the secret provider to read + * it from. When empty the server uses EXPO_TOKEN, then the `eas login` session. + * This is `secretSetting` from settings.ts, which imports this module. */ - accessToken: TrimmedString.pipe( + accessToken: SettingsSecretValue.pipe( Schema.withDecodingDefaultKey(Effect.succeed("")), Schema.annotateKey({ settingsSecret: true }), ), @@ -116,7 +117,7 @@ export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).ch export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), - accessToken: Schema.optional(TrimmedString), + accessToken: Schema.optional(SettingsSecretValue), }); export type DeviceEasAccountInput = typeof DeviceEasAccountInput.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index f9cd98311d19d..9ac89ef499b3f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -15,6 +15,11 @@ export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; export const ORCHESTRATION_PROTOCOL_HEADER = "x-t3-orchestration-protocol"; +/** Wire shapes a client understands, comma-separated; a client that omits one gets the older shape. */ +export const CLIENT_CAPABILITIES_QUERY_PARAM = "clientCapabilities"; +/** Secret fields may hold a secret provider reference instead of a string. */ +export const SETTINGS_SECRET_REFERENCES_CAPABILITY = "settingsSecretReferences"; + export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", "linux", 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 abf2b309858be..785f41e3aa0fb 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -285,6 +285,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"]), 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/rpc.ts b/packages/contracts/src/rpc.ts index 01989ff8a474f..737059e855768 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -595,6 +595,7 @@ export const WS_METHODS = { sourceControlSignInCancel: "sourceControl.signInCancel", sourceControlAppAccess: "sourceControl.appAccess", sourceControlStarred: "sourceControl.starred", + sourceControlStar: "sourceControl.star", projectCloneStart: "projectClone.start", projectCloneCancel: "projectClone.cancel", projectCloneRetry: "projectClone.retry", @@ -1252,6 +1253,13 @@ const WsSourceControlStarredRpc = Rpc.make(WS_METHODS.sourceControlStarred, { error: EnvironmentAuthorizationError, }); +// Stars the repository as the server's forge account. False when the forge refused or cannot star. +const WsSourceControlStarRpc = Rpc.make(WS_METHODS.sourceControlStar, { + payload: SourceControlStarredInput, + success: Schema.Boolean, + error: EnvironmentAuthorizationError, +}); + // Returns when the project exists and cloning starts; progress uses the subscription. const WsProjectCloneStartRpc = Rpc.make(WS_METHODS.projectCloneStart, { payload: ProjectCloneStartInput, @@ -2090,6 +2098,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlSignInCancelRpc, WsSourceControlAppAccessRpc, WsSourceControlStarredRpc, + WsSourceControlStarRpc, WsProjectCloneStartRpc, WsProjectCloneCancelRpc, WsProjectCloneRetryRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f291cec115f73..7f6e8a4aea8dd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -22,6 +22,7 @@ import { import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { UsageModelFilter } from "./usage.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { @@ -858,7 +859,10 @@ export const UsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), url: TrimmedNonEmptyString, - managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting), + managementKey: SettingsSecretValue.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + secretSetting, + ), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; @@ -1392,7 +1396,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), ), ); @@ -1696,7 +1700,7 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), - /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty or omitted secrets remove them. */ + /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty secrets remove them. A non-empty token map keeps the saved tokens of hosts it omits; an empty map clears them all. */ sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), // Replace whole maps to avoid half-merged driver configs. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), 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 e119922970d30..904fbdfcb5c22 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), @@ -756,7 +760,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"), @@ -1762,6 +1769,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 1c9466dd1414d..99b1cfdc9f958 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 4cce056ba72cf..c154b7a2bc6fd 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"; @@ -463,7 +464,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, @@ -495,7 +499,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( @@ -792,7 +796,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 4544a4ac5ce9d..0ef446ef3453a 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"; @@ -1126,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, @@ -1137,6 +1140,7 @@ export class AcpSessionRuntime extends Context.Service< readonly forkSession: ( sessionId: string, options?: AcpSessionActivationOptions, + meta?: NonNullable, ) => Effect.Effect; readonly listSessions: ( cursor?: string, @@ -1236,16 +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 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({ @@ -1255,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>( @@ -1682,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( @@ -1807,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; @@ -1868,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, @@ -1911,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, @@ -2016,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)) { @@ -2037,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, + ), + ), + ), + ), + ), + ); }), ), ), @@ -2249,7 +2284,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, @@ -2406,7 +2441,8 @@ 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) => { @@ -2437,7 +2473,7 @@ 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 = { @@ -2445,6 +2481,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( cwd: options.cwd, ...sessionSetupFields(started.initializeResult), mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + ...(meta === undefined ? {} : { _meta: meta }), } satisfies EffectAcpSchema.ForkSessionRequest; return runLoggedRequest( "session/fork", @@ -2639,7 +2676,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) { @@ -2690,7 +2727,7 @@ export const layer = ( ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom > => Layer.effect(AcpSessionRuntime, make(options)); function sessionConfigOptionsFromSetup( @@ -2742,7 +2779,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; @@ -2751,7 +2790,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 b7c77c4049591..985f6c8ee9887 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -42,10 +42,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"; @@ -311,6 +312,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; @@ -338,10 +345,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, @@ -351,9 +364,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. @@ -390,6 +417,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, @@ -430,6 +492,15 @@ 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. @@ -533,6 +604,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: { @@ -647,15 +720,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 { @@ -668,7 +783,7 @@ function negotiatedCapabilities( ...base.threads, canReadThreadSnapshot: canLoad, canForkThread: canFork, - canForkFromTurn: false, + canForkFromTurn: canFork && forksFromTurn, }, tools: { ...base.tools, @@ -1111,6 +1226,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; } @@ -1241,8 +1368,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 = @@ -1506,7 +1641,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; @@ -1708,6 +1843,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< @@ -2223,8 +2367,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, }, }; @@ -2858,9 +3002,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 @@ -3356,10 +3505,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), ); } } @@ -3725,7 +3876,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); @@ -5012,7 +5164,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; } @@ -5977,10 +6134,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({ @@ -6197,8 +6381,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; @@ -6209,8 +6395,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); @@ -6272,7 +6466,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, @@ -6284,6 +6478,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") @@ -6541,7 +6745,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; @@ -6562,7 +6766,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), @@ -6641,7 +6845,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), @@ -6816,7 +7020,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; @@ -6974,6 +7183,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, @@ -7028,6 +7238,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); @@ -7143,11 +7364,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, @@ -7206,6 +7426,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* () { @@ -7260,9 +7504,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`; @@ -7380,6 +7623,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } yield* Ref.set(activeTurn, null); + steerInjections.clear(); if ( postSettleContinuationEnabled && settledStatus === "completed" && @@ -7674,6 +7918,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, ); @@ -7729,8 +7975,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 = []; @@ -7870,10 +8120,24 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? 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; @@ -8269,7 +8533,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ), ), startTurn, - ...(flavor.supportsCompaction === true + ...(flavor.supportsCompaction === true || flavor.compactSession !== undefined ? { compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => startTurn({ @@ -8278,13 +8542,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) => @@ -8546,7 +8866,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) { @@ -8800,7 +9120,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", @@ -8815,6 +9141,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 a92256d7dd791..9d83da489b306 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 9bdebc55d373b..8927a8c0ae9bd 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -61,6 +61,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", @@ -107,9 +111,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/handoffBudget.ts b/packages/provider-core/src/server/handoffBudget.ts index 4658bc44fc6d3..b1c98806f29d9 100644 --- a/packages/provider-core/src/server/handoffBudget.ts +++ b/packages/provider-core/src/server/handoffBudget.ts @@ -147,7 +147,7 @@ export function historicalMessage( text = item.message; break; case "file_change": - text = `File change: ${item.fileName}`; + text = `File change: ${item.changes?.map((change) => change.path).join(", ") ?? item.fileName}`; break; case "proposed_plan": text = item.markdown; 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/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 6dbd06d976f03..c1f8227eb25ff 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-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/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/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 a5910387aaac1..c2e368641b5af 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("closes SDK sessions and interrupts in-flight credential work before signing out", () => 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 e9f4749ff4655..d34b3721f6ae7 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 = { @@ -339,7 +339,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 c922b315b56ce..7a3ef9dd4b4bf 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"; @@ -63,7 +63,7 @@ const unsupportedTextGeneration: ProviderTextGeneration = (() => { 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 9907ad1441357..61a6b2d482f53 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-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index 8036df0dd3e99..c6ba583c139ce 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -38,6 +38,7 @@ const GrokUsageResponse = Schema.Struct({ }), ), }); +const GrokErrorResponse = Schema.Struct({ error: Schema.String }); function parsePeriodBoundary(value: string | undefined) { if ( @@ -182,6 +183,17 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( HttpClientRequest.bearerToken(token), ), ); + if (response.status === 403) { + const { error } = yield* HttpClientResponse.schemaBodyJson(GrokErrorResponse)(response); + // xAI refuses Grok Build while the account's privacy setting is a legacy mode. + if (error.includes("WKE=privacy-mode:")) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Switch your xAI account to Privacy Mode to show Grok usage limits.", + }); + } + } const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)( yield* HttpClientResponse.filterStatusOk(response), ); 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..b0cfaa0248ec0 --- /dev/null +++ b/packages/provider-kiro/src/server/driver.ts @@ -0,0 +1,154 @@ +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 { ProviderTextGeneration } 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: ProviderTextGeneration = (() => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Kiro does not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + generateReviewGuide: () => unsupported("generateReviewGuide"), + }; +})(); + +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 95da30946e138..4f5f0f8d5b3e4 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"; @@ -159,7 +160,8 @@ export type OpenCodeDriverEnv = | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -193,7 +195,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({ @@ -344,7 +346,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/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 10694ef45ef55..a5a7440257875 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -456,6 +456,67 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { ), ); + it.effect( + "maps OpenCode prompt errors to fixed details and keeps provider text in the cause", + () => + Effect.forEach( + [ + { + error: { + name: "APIError", + data: { + message: "Org quota secret-org exceeded", + statusCode: 429, + isRetryable: true, + }, + }, + rawText: "secret-org", + detail: "The model provider's usage or rate limit was reached. Try again later.", + }, + { + error: { + name: "APIError", + data: { + message: "", + statusCode: 503, + isRetryable: true, + responseBody: "Upstream gateway secret-host", + }, + }, + rawText: "secret-host", + detail: "The model provider failed the request (HTTP 503).", + }, + { + error: { name: "UnknownError", data: { message: "Provider stack trace secret-token" } }, + rawText: "secret-token", + detail: "OpenCode could not generate the text.", + }, + ], + (testCase) => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { info: { error: testCase.error }, parts: [] }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.detail).toBe(testCase.detail); + expect(error.message).not.toContain(testCase.rawText); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptResponseError", + providerErrorName: testCase.error.name, + cause: testCase.error, + }); + expect((error.cause as Error).message).not.toContain(testCase.rawText); + }), + ), + { discard: true }, + ), + ); + it.effect("deletes the session when the prompt request rejects", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index ab8d41f1568b2..570d2ebf6e2f1 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -70,15 +70,40 @@ export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedErro { ...openCodePromptErrorContext, providerErrorName: Schema.optional(Schema.String), - providerMessage: Schema.String, + statusCode: Schema.optional(Schema.Int), + cause: Schema.Defect(), }, ) { override get message(): string { const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; - return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; + const status = this.statusCode === undefined ? "" : `, HTTP ${this.statusCode}`; + return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}${status}).`; } } +function openCodePromptResponseErrorDetail(error: OpenCodeTextGenerationPromptResponseError) { + if ( + error.providerErrorName === "ProviderAuthError" || + error.statusCode === 401 || + error.statusCode === 403 + ) { + return "The model provider rejected the credentials. Check the provider's sign-in or API key in OpenCode."; + } + if (error.statusCode === 429) { + return "The model provider's usage or rate limit was reached. Try again later."; + } + if (error.providerErrorName === "ContextOverflowError") { + return "The prompt is too long for the selected model's context window."; + } + if (error.providerErrorName === "MessageAbortedError") { + return "OpenCode stopped the generation."; + } + if (error.statusCode !== undefined) { + return `The model provider failed the request (HTTP ${error.statusCode}).`; + } + return "OpenCode could not generate the text."; +} + export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError()( "OpenCodeTextGenerationEmptyOutputError", { @@ -94,7 +119,7 @@ export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError 0 ? error.name.trim() : undefined; - const message = - "data" in error && - error.data && - typeof error.data === "object" && - "message" in error.data && - typeof error.data.message === "string" - ? error.data.message.trim() - : ""; - if (message.length > 0) { - return { - ...(name ? { name } : {}), - message, - }; - } - - if (name) { - return { name, message: name }; + const data = "data" in error && error.data && typeof error.data === "object" ? error.data : {}; + const statusCode = + "statusCode" in data && typeof data.statusCode === "number" && Number.isInteger(data.statusCode) + ? data.statusCode + : undefined; + const hasMessage = + "message" in data && typeof data.message === "string" && data.message.trim().length > 0; + if (!name && statusCode === undefined && !hasMessage) { + return null; } - return null; + return { + ...(name ? { name } : {}), + ...(statusCode !== undefined ? { statusCode } : {}), + }; } function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { @@ -263,12 +283,16 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ), ), ); - const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); + const promptError = result.data?.info?.error; + const promptFailure = getOpenCodePromptFailure(promptError); if (promptFailure) { return yield* new OpenCodeTextGenerationPromptResponseError({ ...promptContext, ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), - providerMessage: promptFailure.message, + ...(promptFailure.statusCode !== undefined + ? { statusCode: promptFailure.statusCode } + : {}), + cause: promptError, }); } const responseParts = result.data?.parts ?? []; @@ -312,7 +336,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" Effect.fail( new TextGenerationError({ operation: cause.operation, - detail: cause.providerMessage, + detail: openCodePromptResponseErrorDetail(cause), cause, }), ), 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 f49de61b24690..2e0f76c34ea74 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"; @@ -278,6 +278,7 @@ interface OpenBlock { interface ThreadState { readonly sessionId: string; providerThread: OrchestrationV2ProviderThread; + publishedBackgroundWork?: boolean; readonly providerTurns: Map; active: ActiveTurn | undefined; model: ModelRef | undefined; @@ -355,9 +356,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)}`; }); @@ -524,6 +525,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; @@ -624,14 +631,8 @@ 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"]) => { @@ -670,10 +671,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< @@ -833,6 +834,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* ( @@ -1232,7 +1244,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) { @@ -1248,6 +1260,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) => @@ -1264,11 +1277,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 = ( @@ -1378,11 +1395,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) { @@ -1417,11 +1430,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); }); @@ -1635,11 +1644,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, ); @@ -2152,14 +2157,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, }), }); } @@ -2402,11 +2411,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 ( @@ -2617,10 +2622,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))) { @@ -2676,7 +2683,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* () { @@ -2925,12 +2951,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) => @@ -3147,11 +3180,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; }); @@ -3207,6 +3236,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* ( @@ -3232,6 +3263,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); }), ); }); @@ -3294,6 +3327,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 @@ -3305,6 +3339,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, @@ -3340,16 +3395,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; @@ -4008,7 +4055,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..dc994f90cac5a 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)), ); @@ -1581,6 +1591,9 @@ describe("PiAdapterV2", () => { arg === "--extension" ? [spawn.args[index + 1]] : [], ); assert.isFalse(spawn.args.includes("--no-extensions")); + // A thread's long-lived process keeps Pi's startup model refresh unless the user opts out. + assert.notInclude(spawn.args, "--offline"); + assert.isUndefined(spawn.env.PI_OFFLINE); assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-mcp-extension.ts"))); assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp"); assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token"); @@ -1881,6 +1894,7 @@ describe("PiAdapterV2", () => { assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); assert.include(args, "--no-extensions"); assert.include(args, "--no-tools"); + assert.include(args, "--offline"); assert.notInclude(args, "--no-session"); assert.deepEqual( forkFake @@ -4138,6 +4152,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..a8e140f02b784 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; @@ -3026,6 +3103,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( environment: options.environment, mcpSession: undefined, extensionPath: undefined, + shortLived: true, disableExtensions: true, disableTools: true, }); diff --git a/packages/provider-pi/src/server/driver.test.ts b/packages/provider-pi/src/server/driver.test.ts index 087a8089aaee8..59586fdaedb39 100644 --- a/packages/provider-pi/src/server/driver.test.ts +++ b/packages/provider-pi/src/server/driver.test.ts @@ -169,6 +169,17 @@ it.layer(layerTest)("PiDriver workspace discovery", (it) => { ["--approve", "--skill", "extra skill", "--no-session"], ); assert.equal(workspaceLaunch!.options.env?.PI_CODING_AGENT_DIR, "/isolated-pi"); + // Discovery processes are stopped as soon as they answer, so neither may start Pi's + // network refresh at launch. + for (const cwd of ["/machine", "/first"]) { + const discovery = launches.find( + (launch) => launch.options.cwd === cwd && launch.args.includes("--mode"), + ); + assert.deepEqual( + discovery?.args.filter((arg) => arg === "--offline"), + ["--offline"], + ); + } }).pipe(Effect.scoped), ); diff --git a/packages/provider-pi/src/server/mcpInjection.test.ts b/packages/provider-pi/src/server/mcpInjection.test.ts index 2ca5f35d026f5..4e776058a66a4 100644 --- a/packages/provider-pi/src/server/mcpInjection.test.ts +++ b/packages/provider-pi/src/server/mcpInjection.test.ts @@ -102,6 +102,7 @@ describe("pi T3 MCP injection", () => { extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", sessionPath: "/fake/persisted.jsonl", ephemeral: true, + shortLived: true, disableExtensions: true, disableTools: true, }); @@ -111,9 +112,19 @@ describe("pi T3 MCP injection", () => { "--no-session", "--provider", "anthropic", + "--offline", "--no-extensions", "--no-tools", ]); + const alreadyOffline = buildPiRpcLaunch({ + launchArgs: ["--offline"], + environment: {}, + mcpSession: undefined, + extensionPath: undefined, + ephemeral: true, + shortLived: true, + }); + assert.deepEqual(alreadyOffline.args, ["--mode", "rpc", "--no-session", "--offline"]); assert.deepInclude(resolvePiLaunchArgs("--mode text"), { ok: false, message: "Pi launch argument '--mode' is controlled by T3 Code and cannot be overridden.", diff --git a/packages/provider-pi/src/server/mcpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts index 51848c424ca05..ab92ab73dde7a 100644 --- a/packages/provider-pi/src/server/mcpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -244,6 +244,9 @@ export function buildPiRpcLaunch(input: { readonly extensionPath: string | undefined; readonly sessionPath?: string | undefined; readonly ephemeral?: boolean; + // T3 stops these processes as soon as they answer. They run --offline because Pi's startup + // catalog refresh renews expired OAuth tokens, and a stop mid-request loses the rotated one. + readonly shortLived?: boolean; readonly disableExtensions?: boolean; readonly disableTools?: boolean; readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; @@ -268,6 +271,7 @@ export function buildPiRpcLaunch(input: { ? ["--session", input.sessionPath] : []), ...launchArgs, + ...(input.shortLived === true && !launchArgs.includes("--offline") ? ["--offline"] : []), // Restrictions must follow launch args so user flags cannot re-enable unattended tools/extensions. ...(input.disableExtensions === true ? ["--no-extensions"] : []), ...(input.disableTools === true ? ["--no-tools"] : []), diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index dfafa75ccd579..4775689c8c2b7 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -150,6 +150,7 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( mcpSession: undefined, extensionPath: undefined, ephemeral: true, + shortLived: true, }); const connection = yield* makePiRpcConnection({ command: piSettings.binaryPath || "pi", diff --git a/packages/provider-pi/src/server/textGeneration.test.ts b/packages/provider-pi/src/server/textGeneration.test.ts index ba693da18012b..0e6a8a3c07780 100644 --- a/packages/provider-pi/src/server/textGeneration.test.ts +++ b/packages/provider-pi/src/server/textGeneration.test.ts @@ -7,6 +7,7 @@ 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 * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { makePiTextGeneration } from "./textGeneration.ts"; @@ -68,6 +69,35 @@ const makeFakePi = ( return { spawner, received }; }); +it.effect("runs Pi offline, since the process is stopped as soon as it answers", () => + Effect.gen(function* () { + const pi = yield* makeFakePi(JSON.stringify({ subject: "Update README", body: "" })); + const launches: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) launches.push(command.args.map(String)); + return pi.spawner.spawn(command); + }); + const textGeneration = yield* makePiTextGeneration( + { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, + {}, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "default"), + }); + + assert.equal(result.subject, "Update README"); + assert.deepEqual( + launches.map((args) => args.filter((arg) => arg === "--offline")), + [["--offline"]], + ); + }), +); + it.effect("names the model and the disabled extensions when Pi cannot load it", () => Effect.gen(function* () { const pi = yield* makeFakePi("", "Model not found: demo/demo-model"); diff --git a/packages/provider-pi/src/server/textGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts index 3fed9963b6cea..40697f2d4280a 100644 --- a/packages/provider-pi/src/server/textGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -47,6 +47,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* mcpSession: undefined, extensionPath: undefined, ephemeral: true, + shortLived: true, disableExtensions: true, // Background naming/content helpers must never mutate the workspace. disableTools: 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/cliRelease.ts b/packages/shared/src/cliRelease.ts index 66777cc044478..80eeec4397545 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -50,6 +50,22 @@ export function cliReleaseDownloadBaseUrl( return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`; } +/** `segments` added to `baseUrl`'s path, keeping its query, such as a signed mirror URL's token. */ +export function releaseAssetUrl(baseUrl: string, ...segments: ReadonlyArray): string { + const url = new URL(baseUrl); + url.pathname = [url.pathname.replace(/\/+$/, ""), ...segments.map(encodeURIComponent)].join("/"); + return url.toString(); +} + +/** A file of release `version`, from `baseUrl` laid out as GitHub's release downloads. */ +export function cliReleaseAssetUrl( + version: string, + fileName: string, + baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL, +): string { + return releaseAssetUrl(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL, `v${version}`, fileName); +} + export function parseChecksums(text: string): ReadonlyMap { const checksums = new Map(); for (const line of text.split(/\r?\n/)) { @@ -86,6 +102,21 @@ export function desktopUpdateChannelOf(version: string): "latest" | "nightly" { return cliReleaseChannelOf(version) === "nightly" ? "nightly" : "latest"; } +/** + * The update feed file a desktop app on this platform reads, which an update mirror publishes at + * its root beside the release's CLI archives. + */ +export function updateFeedFileName( + channel: "latest" | "nightly", + platform: NodeJS.Platform, + arch: string, +): string { + if (platform === "darwin") return `${channel}-mac.yml`; + if (platform === "linux") + return arch === "arm64" ? `${channel}-linux-arm64.yml` : `${channel}-linux.yml`; + return `${channel}.yml`; +} + /** Whether a track switch may install `found` after its database check cleared `checked`: any build * on the switch's track at least as new, since later releases only add migrations. */ export function acceptsTrackSwitchRelease( 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 914d65ec760c2..33e897379619e 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -67,11 +67,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; } @@ -289,7 +293,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/managedPolicyFile.test.ts b/packages/shared/src/managedPolicyFile.test.ts index f6cf7ef8c2cb6..2ab3ea710e3ec 100644 --- a/packages/shared/src/managedPolicyFile.test.ts +++ b/packages/shared/src/managedPolicyFile.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; @@ -12,6 +13,7 @@ import { MACOS_MANAGED_PREFERENCES_PATH, managedPolicyPath, readManagedPolicy, + type RegistryKey, WINDOWS_POLICY_KEY, layerWindowsRegistry, WindowsRegistry, @@ -23,8 +25,10 @@ const policy = JSON.stringify({ schemaVersion: 1, organization: "Example Corp" } /** What a command prints and exits with. */ type Result = { readonly stdout: string; readonly exitCode: number }; -/** The registry's `Policy` value: its text, absent, or the Win32 error reading it fails with. */ -type RegistryAnswer = string | undefined | { readonly code?: number }; +/** Keys below the policy key ("" for the key itself), or the error every read fails with. */ +type RegistryAnswer = Readonly> | WindowsRegistryError; +const isRegistryError = Schema.is(WindowsRegistryError); +const POLICY_SUBKEY = WINDOWS_POLICY_KEY.slice("HKLM\\".length); /** Files that exist, what a command prints and exits with by command name, and the registry. */ const machine = ( @@ -36,10 +40,10 @@ const machine = ( Layer.succeed( WindowsRegistry, WindowsRegistry.of({ - readMachineString: () => - typeof registry === "object" - ? Effect.fail(new WindowsRegistryError(registry)) - : Effect.succeed(registry), + readMachineKey: (subkey) => + isRegistryError(registry) + ? Effect.fail(registry) + : Effect.succeed(registry[subkey.slice(POLICY_SUBKEY.length + 1)]), }), ), FileSystem.layerNoop({ @@ -70,11 +74,34 @@ const machine = ( ), ); +/** A policy key holding `values`, with checkbox subkeys of `name=0|1` entries. */ +const policyKey = ( + values: RegistryKey["values"], + subkeys: Readonly>>> = {}, +): Readonly> => ({ + "": { values, subkeys: Object.keys(subkeys) }, + ...Object.fromEntries( + Object.entries(subkeys).map(([name, boxes]) => [ + name, + { + values: Object.entries(boxes).map(([box, data]) => ({ + name: box, + type: "REG_DWORD" as const, + data, + })), + subkeys: [], + }, + ]), + ), +}); +const text = (name: string, data: string) => ({ name, type: "REG_SZ" as const, data }); +const dword = (name: string, data: number) => ({ name, type: "REG_DWORD" as const, data }); + const read = ( platform: NodeJS.Platform, files: Readonly>, commands: Parameters[1] = {}, - registry: RegistryAnswer = undefined, + registry: RegistryAnswer = {}, ) => readManagedPolicy(platform).pipe( Effect.map((found) => found?.source), @@ -139,52 +166,189 @@ describe("readManagedPolicy", () => { ); it.effect( - "reads the Windows Policies registry value, and treats only an absent one as none", + "reads a Policy value of JSON from the registry, and treats only an absent key as none", () => Effect.gen(function* () { const file = { [managedPolicyPath("win32")]: policy }; const win32 = (files: Readonly>, registry: RegistryAnswer) => read("win32", files, {}, registry); + const json = policyKey([text("Policy", policy)]); - assert.equal(yield* win32({}, policy), WINDOWS_POLICY_KEY); - assert.isUndefined(yield* win32({}, undefined)); - assert.equal(yield* win32(file, undefined), managedPolicyPath("win32")); - assert.include(yield* win32(file, policy), "deploy the policy one way"); + assert.equal(yield* win32({}, json), WINDOWS_POLICY_KEY); + assert.isUndefined(yield* win32({}, {})); + assert.equal(yield* win32(file, {}), managedPolicyPath("win32")); + assert.include(yield* win32(file, json), "deploy the policy one way"); // A read that fails, here access denied, is not read as no policy, file or not. for (const files of [{}, file]) { assert.include( - yield* win32(files, { code: 5 }), + yield* win32(files, new WindowsRegistryError({ code: 5 })), "the registry cannot be read (Windows error 5)", ); } - assert.include(yield* win32({}, {}), "cannot load its registry reader; reinstall T3 Code"); assert.include( - yield* win32({}, { code: 1630 }), + yield* win32({}, new WindowsRegistryError({})), + "cannot load its registry reader; reinstall T3 Code", + ); + assert.include( + yield* win32({}, policyKey([{ name: "Policy", type: "other" }])), "its Policy value is not a string (REG_SZ)", ); - assert.include(yield* win32({}, "{ not json"), "does not match the policy schema"); + assert.include( + yield* win32({}, policyKey([text("Policy", "{ not json")])), + "does not match the policy schema", + ); + for (const both of [ + policyKey([text("Policy", policy), text("Organization", "Example")]), + policyKey([text("Policy", policy)], { Clients: { desktop: 1 } }), + ]) { + assert.include(yield* win32({}, both), "both a Policy value and Group Policy settings"); + } + // Not Configured clears a list policy's values but can leave its empty subkey behind. + assert.equal( + yield* win32({}, policyKey([text("Policy", policy)], { Clients: {} })), + WINDOWS_POLICY_KEY, + ); }), ); + it.effect("builds the policy from the values the Group Policy template writes", () => + Effect.gen(function* () { + const organization = text("Organization", "Société 日本 ✓"); + const document = (registry: RegistryAnswer) => + readManagedPolicy("win32", ["codex", "claudeAgent"]).pipe( + Effect.map((found): unknown => found?.document), + Effect.catchTags({ + ManagedPolicyFileError: (error) => Effect.succeed(`error: ${error.reason}`), + }), + Effect.provide(machine({}, {}, registry)), + ); + + assert.deepStrictEqual( + yield* document( + policyKey( + [ + organization, + text("SupportUrl", ""), + // Registry names ignore case. + text("maxRuntimeMode", "auto-accept-edits"), + dword("Telemetry", 0), + text("Settings", '{"enableAgentBrowserAccess": {"value": false, "locked": true}}'), + text("UpdatesMode", "off"), + dword("AllowNightly", 1), + dword("Updates", 1), + dword("Clients", 1), + dword("AllowedDrivers", 1), + ], + // Registry names ignore case, so a subkey written in other case still counts. + { + clients: { Desktop: 1, "LOCAL-BROWSER": 1, REMOTE: 1 }, + AllowedDrivers: { Codex: 1, CLAUDEAGENT: 1, UnknownDriver: 1 }, + }, + ), + ), + { + schemaVersion: 1, + organization: "Société 日本 ✓", + settings: { enableAgentBrowserAccess: { value: false, locked: true } }, + limits: { + maxRuntimeMode: "auto-accept-edits", + telemetry: false, + updates: { mode: "off", allowNightly: true }, + clients: ["desktop", "local-browser", "remote"], + providers: { allowedDrivers: ["codex", "claudeAgent", "UnknownDriver"] }, + }, + }, + ); + + // Disabling a policy leaves each checkbox's falseValue behind; it restricts nothing. + const disabledGroups = { + Clients: { desktop: 0, "local-browser": 0, remote: 0 }, + AllowedDrivers: { codex: 0, claudeAgent: 0 }, + }; + assert.deepStrictEqual( + yield* document( + policyKey( + [ + organization, + dword("Clients", 0), + dword("AllowedDrivers", 0), + dword("Updates", 0), + dword("AllowNightly", 0), + dword("Telemetry", 0), + ], + disabledGroups, + ), + ), + { schemaVersion: 1, organization: "Société 日本 ✓", limits: { telemetry: false } }, + ); + // With nothing else configured, that is no policy rather than one missing Organization. + assert.isUndefined( + yield* document( + policyKey([dword("AllowedDrivers", 0)], { + AllowedDrivers: disabledGroups.AllowedDrivers, + }), + ), + ); + assert.isUndefined(yield* document(policyKey([]))); + + // Anything the template does not write fails closed rather than being ignored. + for (const [registry, reason] of [ + [policyKey([organization, dword("Telemetry", 2)]), "DWORD of 0 or 1"], + [policyKey([organization, text("MaxMode", "auto")]), "not a T3 Code policy value"], + [policyKey([organization], { Other: { x: 1 } }), "Other is not a T3 Code policy key"], + [ + { + ...policyKey([organization, dword("Clients", 1)], { Clients: { desktop: 1 } }), + Clients: { values: [dword("desktop", 1)], subkeys: ["Nested"] }, + }, + "Clients\\Nested is not a T3 Code policy key", + ], + [policyKey([organization, text("Settings", "{")]), "not valid JSON"], + [policyKey([organization, dword("Clients", 2)]), "DWORD of 0 or 1"], + [ + policyKey([organization, dword("Clients", 1)], { Clients: { UnknownClient: 1 } }), + "does not match the policy schema", + ], + [ + policyKey([organization, dword("Clients", 1)], { Clients: { desktop: 0 } }), + "does not match the policy schema", + ], + [policyKey([dword("Organization", 1)]), "Organization must be a string"], + // REG_EXPAND_SZ would fill in the reading user's variables. + [policyKey([{ name: "Organization", type: "other" }]), "Organization must be a string"], + [ + policyKey([text("MaxRuntimeMode", "auto-accept-edits")]), + "set the Organization policy whenever another T3 Code policy is set", + ], + ] as const) { + assert.include(yield* document(registry), reason); + } + }), + ); + it.effect.runIf(HostProcess.Platform.defaultValue() === "win32")( - "reads REG_SZ values through the Win32 API, and refuses other types", + "lists registry values and subkeys through the Win32 API", () => Effect.gen(function* () { const registry = yield* WindowsRegistry; - const version = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"; - assert.match((yield* registry.readMachineString(version, "ProductName")) ?? "", /Windows/); - assert.isUndefined(yield* registry.readMachineString(version, "T3CodeNoSuchValue")); - assert.isUndefined( - yield* registry.readMachineString("SOFTWARE\\T3CodeNoSuchKey", "Policy"), + const version = yield* registry.readMachineKey( + "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", ); - // REG_DWORD, and REG_EXPAND_SZ, which would otherwise expand the user's variables. - for (const [key, value] of [ - [version, "CurrentMajorVersionNumber"], - ["SOFTWARE\\Microsoft\\Windows\\CurrentVersion", "ProgramFilesPath"], - ] as const) { - const error = yield* registry.readMachineString(key, value).pipe(Effect.flip); - assert.equal(error.code, 1630); - } + const value = (name: string) => version?.values.find((entry) => entry.name === name); + const product = value("ProductName"); + assert.match(product?.type === "REG_SZ" ? product.data : "", /Windows/); + assert.equal(value("CurrentMajorVersionNumber")?.type, "REG_DWORD"); + assert.isUndefined(yield* registry.readMachineKey("SOFTWARE\\T3CodeNoSuchKey")); + const windows = yield* registry.readMachineKey( + "SOFTWARE\\Microsoft\\Windows\\CurrentVersion", + ); + // REG_EXPAND_SZ, which would otherwise expand the user's variables. + assert.equal( + windows?.values.find((entry) => entry.name === "ProgramFilesPath")?.type, + "other", + ); + const parent = yield* registry.readMachineKey("SOFTWARE\\Microsoft\\Windows NT"); + assert.isTrue(parent?.subkeys.includes("CurrentVersion")); }).pipe(Effect.provide(layerWindowsRegistry(() => import("ffi-rs")))), ); }); diff --git a/packages/shared/src/managedPolicyFile.ts b/packages/shared/src/managedPolicyFile.ts index e7e44608d838c..e6faf3b711239 100644 --- a/packages/shared/src/managedPolicyFile.ts +++ b/packages/shared/src/managedPolicyFile.ts @@ -1,4 +1,8 @@ -import { ManagedPolicyDocument } from "@t3tools/contracts"; +import { + ManagedPolicyClient, + ManagedPolicyDocument, + SERVER_EXIT_CODE_MANAGED_POLICY, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -6,6 +10,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Runtime from "effect/Runtime"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -20,6 +25,8 @@ export class ManagedPolicyFileError extends Schema.TaggedError } +>; + +/** Subkeys of checkboxes: every value set to 1 is one entry of the list at `path`. */ +export const WINDOWS_POLICY_LISTS = { + Clients: ["limits", "clients"], + AllowedDrivers: ["limits", "providers", "allowedDrivers"], +} as const satisfies Record>; +// A disabled policy still writes its checkboxes' falseValue; its own DWORD disambiguates it. +export const WINDOWS_POLICY_GROUPS = { + ...WINDOWS_POLICY_LISTS, + Updates: ["limits", "updates"], +} as const satisfies Record>; // An absolute path: an earlier PATH entry could otherwise answer that there is no policy. const PLUTIL = "/usr/bin/plutil"; @@ -109,30 +149,50 @@ export class WindowsRegistryError extends Schema.TaggedError; + readonly subkeys: ReadonlyArray; +} + /** - * Reads the registry through the Win32 API in this process. A missing value and a failed read - * come back apart, and blocked registry tools such as reg.exe do not matter. + * Reads the registry through the Win32 API in this process. A missing key and a failed read come + * back apart, and blocked registry tools such as reg.exe do not matter. */ export class WindowsRegistry extends Context.Service< WindowsRegistry, { - /** The REG_SZ `value` of HKLM\\`subkey` in the 64-bit view; undefined when either is absent. */ - readonly readMachineString: ( + /** The values and subkey names of HKLM\\`subkey` in the 64-bit view; undefined when absent. */ + readonly readMachineKey: ( subkey: string, - value: string, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/shared/managedPolicyFile/WindowsRegistry") {} // (HKEY)(LONG)0x80000002, sign-extended to 64 bits. const HKEY_LOCAL_MACHINE = -0x7ffffffen; -const RRF_RT_REG_SZ = 0x2; -const RRF_SUBKEY_WOW6464KEY = 0x10000; -// Without it a REG_EXPAND_SZ value passes as REG_SZ, with the user's variables filled in. -const RRF_NOEXPAND = 0x10000000; +const KEY_READ = 0x20019; +const KEY_WOW64_64KEY = 0x100; +const REG_SZ = 1; +const REG_DWORD = 4; const ERROR_FILE_NOT_FOUND = 2; const ERROR_MORE_DATA = 234; -const ERROR_UNSUPPORTED_TYPE = 1630; +const ERROR_NO_MORE_ITEMS = 259; +// Value and key names are at most 16,383 characters. +const MAX_NAME_CHARS = 16_384; + +class Win32Failure extends Error { + readonly code: number; + constructor(code: number) { + super(`Windows error ${code}`); + this.code = code; + } +} /** * Each app passes how it loads ffi-rs: a server single-executable resolves packages on disk only @@ -143,67 +203,129 @@ export const layerWindowsRegistry = (loadFfi: () => Promise { - // HKEY_LOCAL_MACHINE and the pointer arguments below are 64-bit. + // HKEY_LOCAL_MACHINE, key handles, and the null pointers below are 64-bit. if (architecture !== "x64" && architecture !== "arm64") { throw new Error(`no registry access on ${architecture}`); } const { DataType, load, open } = await loadFfi(); open({ library: "t3-advapi32", path: "advapi32.dll" }); - return (subkey: string, value: string, data: Buffer, size: Buffer): number => + const call = ( + funcName: string, + paramsType: Array<(typeof DataType)[keyof typeof DataType]>, + paramsValue: Array, + ): number => load({ library: "t3-advapi32", - funcName: "RegGetValueW", + funcName, retType: DataType.I32, - paramsType: [ - DataType.BigInt, - DataType.WString, - DataType.WString, - DataType.U32, - DataType.BigInt, - DataType.U8Array, - DataType.U8Array, - ], - paramsValue: [ - HKEY_LOCAL_MACHINE, - subkey, - value, - RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY | RRF_NOEXPAND, - 0n, - data, - size, - ], - }); + paramsType, + paramsValue, + } as Parameters[0]) as number; + const { BigInt: Handle, U32, U8Array: Out, WString } = DataType; + return { + open: (subkey: string, handle: Buffer) => + call( + "RegOpenKeyExW", + [Handle, WString, U32, U32, Out], + [HKEY_LOCAL_MACHINE, subkey, 0, KEY_READ | KEY_WOW64_64KEY, handle], + ), + enumValue: ( + key: bigint, + index: number, + name: Buffer, + nameChars: Buffer, + type: Buffer, + data: Buffer, + dataBytes: Buffer, + ) => + call( + "RegEnumValueW", + [Handle, U32, Out, Out, Handle, Out, Out, Out], + [key, index, name, nameChars, 0n, type, data, dataBytes], + ), + enumKey: (key: bigint, index: number, name: Buffer, nameChars: Buffer) => + call( + "RegEnumKeyExW", + [Handle, U32, Out, Out, Handle, Handle, Handle, Handle], + [key, index, name, nameChars, 0n, 0n, 0n, 0n], + ), + close: (key: bigint) => call("RegCloseKey", [Handle], [key]), + }; }, catch: (cause) => new WindowsRegistryError({ cause }), }), ); + + const readKey = (api: Effect.Success, subkey: string) => { + const handle = Buffer.alloc(8); + const opened = api.open(subkey, handle); + if (opened === ERROR_FILE_NOT_FOUND) return undefined; + if (opened !== 0) throw new Win32Failure(opened); + const key = handle.readBigInt64LE(); + try { + const name = Buffer.alloc(MAX_NAME_CHARS * 2); + const chars = Buffer.alloc(4); + const values: Array = []; + for (let index = 0; ; index++) { + let data = Buffer.alloc(64 * 1024); + const type = Buffer.alloc(4); + const bytes = Buffer.alloc(4); + const attempt = () => { + chars.writeUInt32LE(MAX_NAME_CHARS); + bytes.writeUInt32LE(data.length); + return api.enumValue(key, index, name, chars, type, data, bytes); + }; + let code = attempt(); + // The value is larger than the buffer; the API reported the size it needs. + if (code === ERROR_MORE_DATA) { + data = Buffer.alloc(bytes.readUInt32LE()); + code = attempt(); + } + if (code === ERROR_NO_MORE_ITEMS) break; + if (code !== 0) throw new Win32Failure(code); + const valueName = name.subarray(0, chars.readUInt32LE() * 2).toString("utf16le"); + const stored = data.subarray(0, bytes.readUInt32LE()); + const valueType = type.readUInt32LE(); + values.push( + valueType === REG_SZ + ? { + name: valueName, + type: "REG_SZ", + data: stored.toString("utf16le").replace(/\0+$/, ""), + } + : valueType === REG_DWORD && stored.length === 4 + ? { name: valueName, type: "REG_DWORD", data: stored.readUInt32LE() } + : { name: valueName, type: "other" }, + ); + } + const subkeys: Array = []; + for (let index = 0; ; index++) { + chars.writeUInt32LE(MAX_NAME_CHARS); + const code = api.enumKey(key, index, name, chars); + if (code === ERROR_NO_MORE_ITEMS) break; + if (code !== 0) throw new Win32Failure(code); + subkeys.push(name.subarray(0, chars.readUInt32LE() * 2).toString("utf16le")); + } + return { values, subkeys }; + } finally { + api.close(key); + } + }; + return WindowsRegistry.of({ - readMachineString: Effect.fn("WindowsRegistry.readMachineString")( - function* (subkey, value) { - const getValue = yield* loadGetValue; - const attempt = (bytes: number) => - Effect.try({ - try: () => { - const data = Buffer.alloc(bytes); - const size = Buffer.alloc(4); - size.writeUInt32LE(bytes); - const code = getValue(subkey, value, data, size); - return { code, data, bytes: size.readUInt32LE() }; - }, - catch: (cause) => new WindowsRegistryError({ cause }), - }); - let result = yield* attempt(64 * 1024); - // The value grew past the buffer; the API reported the size it needs. - if (result.code === ERROR_MORE_DATA) result = yield* attempt(result.bytes); - if (result.code === ERROR_FILE_NOT_FOUND) return undefined; - if (result.code !== 0) return yield* new WindowsRegistryError({ code: result.code }); - // The size counts the terminating null RegGetValueW adds. - return result.data.subarray(0, result.bytes).toString("utf16le").replace(/\0+$/, ""); - }, - ), + readMachineKey: Effect.fn("WindowsRegistry.readMachineKey")(function* (subkey) { + const api = yield* loadApi; + return yield* Effect.try({ + try: () => readKey(api, subkey), + catch: (cause) => + new WindowsRegistryError( + cause instanceof Win32Failure ? { code: cause.code, cause } : { cause }, + ), + }); + }), }); }), ); @@ -226,6 +348,115 @@ const run = Effect.fnUntraced( ); const decodeJsonExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +type PolicyRegistryValue = RegistryValue & { + /** The list subkey the value sits in, or "" for the policy key itself. */ + readonly key: string; +}; + +const setPath = (target: Record, path: ReadonlyArray, value: unknown) => { + let node = target; + for (const segment of path.slice(0, -1)) { + node = (node[segment] ??= {}) as Record; + } + node[path.at(-1)!] = value; +}; + +const dwordBoolean = (value: RegistryValue) => + value.type !== "REG_DWORD" + ? undefined + : value.data === 0 + ? false + : value.data === 1 + ? true + : undefined; + +/** + * The document the Group Policy template's values describe, undefined when they describe none, or + * why they do not describe one. + */ +function windowsPolicyDocument( + values: ReadonlyArray, + knownDrivers: ReadonlyArray, +): { readonly document: unknown } | { readonly error: string } { + // Registry names ignore case; a hand-written `organization` is still Organization. + const canonical = new Map( + [...Object.keys(WINDOWS_POLICY_VALUES), ...Object.keys(WINDOWS_POLICY_GROUPS)].map((name) => [ + name.toLowerCase(), + name, + ]), + ); + values = values.map((value) => + value.key === "" + ? { ...value, name: canonical.get(value.name.toLowerCase()) ?? value.name } + : value, + ); + const document: Record = { schemaVersion: 1 }; + const lists = new Map>(); + const disabled: Array> = []; + for (const value of values) { + if (value.key !== "" || !Object.hasOwn(WINDOWS_POLICY_GROUPS, value.name)) continue; + const enabled = dwordBoolean(value); + if (enabled === undefined) return { error: `${value.name} must be a DWORD of 0 or 1` }; + if (!enabled) { + disabled.push(WINDOWS_POLICY_GROUPS[value.name as keyof typeof WINDOWS_POLICY_GROUPS]); + } else if (Object.hasOwn(WINDOWS_POLICY_LISTS, value.name)) { + lists.set(value.name, []); + } + } + const put = (path: ReadonlyArray, value: unknown) => { + if (!disabled.some((prefix) => prefix.every((segment, index) => path[index] === segment))) { + setPath(document, path, value); + } + }; + for (const value of values) { + const where = value.key === "" ? value.name : `${value.key}\\${value.name}`; + if (value.key !== "") { + const checked = dwordBoolean(value); + if (checked === undefined) return { error: `${where} must be a DWORD of 0 or 1` }; + const list = lists.get(value.key) ?? []; + lists.set(value.key, list); + if (checked) { + const names = value.key === "Clients" ? ManagedPolicyClient.literals : knownDrivers; + list.push( + names.find((name) => name.toLowerCase() === value.name.toLowerCase()) ?? value.name, + ); + } + continue; + } + if (Object.hasOwn(WINDOWS_POLICY_GROUPS, value.name)) continue; + if (!Object.hasOwn(WINDOWS_POLICY_VALUES, value.name)) { + return { error: `${value.name} is not a T3 Code policy value` }; + } + const field = WINDOWS_POLICY_VALUES[value.name as keyof typeof WINDOWS_POLICY_VALUES]; + if (field.type === "boolean") { + const flag = dwordBoolean(value); + if (flag === undefined) return { error: `${where} must be a DWORD of 0 or 1` }; + put(field.path, flag); + continue; + } + if (value.type !== "REG_SZ") return { error: `${where} must be a string (REG_SZ)` }; + // Group Policy writes an empty string for a box left blank. + if (value.data === "") continue; + if (field.type === "string") { + put(field.path, value.data); + continue; + } + const parsed = decodeJsonExit(value.data); + if (Exit.isFailure(parsed)) return { error: `${where} is not valid JSON` }; + put(field.path, parsed.value); + } + for (const [key, list] of lists) { + put(WINDOWS_POLICY_LISTS[key as keyof typeof WINDOWS_POLICY_LISTS], list); + } + // Policies set to Disabled leave values behind that restrict nothing. + if (Object.keys(document).length === 1) return { document: undefined }; + // The schema would name `["organization"]`, which an admin cannot map to a Group Policy setting. + if (document.organization === undefined) { + return { error: "set the Organization policy whenever another T3 Code policy is set" }; + } + return { document }; +} + // Strict: keys beside the wrapper, or more than one entry, decode as a plain document and fail. const decodeForcedExit = Schema.decodeUnknownExit( Schema.Struct({ @@ -258,7 +489,10 @@ function unwrapForced(json: string): unknown { } /** Read the policy a configuration profile or Group Policy set; absent means no such policy. */ -const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platform) { +const readPlatformPolicy = Effect.fnUntraced(function* ( + platform: NodeJS.Platform, + knownDrivers: ReadonlyArray, +) { if (platform === "darwin") { const path = MACOS_MANAGED_PREFERENCES_PATH; const exists = yield* (yield* FileSystem.FileSystem) @@ -283,26 +517,61 @@ const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platfor } if (platform === "win32") { const path = WINDOWS_POLICY_KEY; - const raw = yield* (yield* WindowsRegistry) - .readMachineString(WINDOWS_POLICY_SUBKEY, "Policy") - .pipe( + const registry = yield* WindowsRegistry; + const readKey = (subkey: string) => + registry.readMachineKey(subkey).pipe( Effect.mapError( (cause) => new ManagedPolicyFileError({ path, reason: - cause.code === ERROR_UNSUPPORTED_TYPE - ? "its Policy value is not a string (REG_SZ)" - : cause.code === undefined - ? "this T3 Code install cannot load its registry reader; reinstall T3 Code" - : `the registry cannot be read (${cause.message})`, + cause.code === undefined + ? "this T3 Code install cannot load its registry reader; reinstall T3 Code" + : `the registry cannot be read (${cause.message})`, cause, }), ), ); - return raw === undefined - ? undefined - : { source: path, document: yield* decodeDocument(path, raw) }; + const refuse = (reason: string) => new ManagedPolicyFileError({ path, reason }); + const root = yield* readKey(WINDOWS_POLICY_SUBKEY); + if (root === undefined) return undefined; + const json = root.values.find((value) => value.name.toLowerCase() === "policy"); + if (json !== undefined) { + // Not Configured clears a list policy's values but can leave its empty subkey behind. + let templateSettings = root.values.length > 1; + for (const subkey of root.subkeys) { + if (templateSettings) break; + const child = yield* readKey(`${WINDOWS_POLICY_SUBKEY}\\${subkey}`); + templateSettings = + child !== undefined && (child.values.length > 0 || child.subkeys.length > 0); + } + if (templateSettings) { + return yield* refuse( + "it holds both a Policy value and Group Policy settings; deploy the policy one way", + ); + } + if (json.type !== "REG_SZ") return yield* refuse("its Policy value is not a string (REG_SZ)"); + return { source: path, document: yield* decodeDocument(path, json.data) }; + } + const values: Array = root.values.map((value) => ({ ...value, key: "" })); + for (const subkey of root.subkeys) { + // Registry names ignore case. + const list = Object.keys(WINDOWS_POLICY_LISTS).find( + (name) => name.toLowerCase() === subkey.toLowerCase(), + ); + if (list === undefined) return yield* refuse(`${subkey} is not a T3 Code policy key`); + const checkboxes = yield* readKey(`${WINDOWS_POLICY_SUBKEY}\\${subkey}`); + if (checkboxes === undefined) continue; + if (checkboxes.subkeys.length > 0) { + return yield* refuse(`${subkey}\\${checkboxes.subkeys[0]} is not a T3 Code policy key`); + } + values.push(...checkboxes.values.map((value) => ({ ...value, key: list }))); + } + const built = windowsPolicyDocument(values, knownDrivers); + if ("error" in built) return yield* refuse(built.error); + // An empty key, or one holding only policies set to Disabled, sets nothing. + if (built.document === undefined) return undefined; + return { source: path, document: yield* decodeDocument(path, built.document) }; } return undefined; }); @@ -314,10 +583,11 @@ const readPlatformPolicy = Effect.fnUntraced(function* (platform: NodeJS.Platfor */ export const readManagedPolicy = Effect.fn("readManagedPolicy")(function* ( platform: NodeJS.Platform, + knownDrivers: ReadonlyArray = [], ) { const filePath = managedPolicyPath(platform); const [platformPolicy, file] = yield* Effect.all([ - readPlatformPolicy(platform), + readPlatformPolicy(platform, knownDrivers), readManagedPolicyFile(filePath), ]); if (platformPolicy !== undefined && file !== undefined) { 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/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index a897f3d427a34..cfbc9339e4eef 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -127,6 +127,24 @@ describe("serverSettings helpers", () => { }); }); + it("keeps a source control host's saved tokens when a patch sets another host's token", () => { + const reference = { provider: "vault", reference: "github/token" }; + const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + sourceControlHosts: { github: { tokens: { "github.com": reference } } }, + }); + const patched = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { "ghe.example": "x" } } }, + }); + expect(patched.sourceControlHosts.github?.tokens).toEqual({ + "github.com": reference, + "ghe.example": "x", + }); + const recased = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { " GitHub.com ": "ghp_new" } } }, + }); + expect(recased.sourceControlHosts.github?.tokens).toEqual({ "github.com": "ghp_new" }); + }); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { const project = { id: ProjectId.make("project-actions"), scripts: [] }; const action = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e936c9f83acba..0db213a90f7b6 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -23,6 +23,7 @@ import { withIdleCompaction, } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; @@ -226,6 +227,15 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +// Secret persistence keys tokens by trimmed, lowercased host, so merge on the same key. +const byHost = (tokens: unknown) => + Object.fromEntries( + Object.entries(Predicate.isObject(tokens) ? tokens : {}).map(([host, token]) => [ + host.trim().toLowerCase(), + token, + ]), + ); + const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { @@ -530,10 +540,26 @@ export function applyServerSettingsPatch( sourceControlHosts: { ...current.sourceControlHosts, ...Object.fromEntries( - Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ - kind, - { ...current.sourceControlHosts[kind], ...fields }, - ]), + Object.entries(sourceControlHostsPatch).map(([kind, fields]) => { + const saved = current.sourceControlHosts[kind]; + return [ + kind, + { + ...saved, + ...fields, + // Older clients send the whole token map; a host missing from it keeps its saved + // token. An empty map still clears them all. + ...(Predicate.isObject(fields.tokens) && Object.keys(fields.tokens).length > 0 + ? { + tokens: { + ...byHost(saved?.tokens), + ...byHost(fields.tokens), + }, + } + : {}), + }, + ]; + }), ), }, } diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 23447ae7930b0..72a17323386c8 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -672,6 +672,57 @@ describe("pools", () => { expect(accounts[0]?.displayName).toBe("claude-team-seat"); }); + it("labels a default-named instance merged with a hub account by e-mail, keeping a chosen name", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claudeAgent"), + displayName: "Claude", + auth: { status: "authenticated", email: "a@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const named = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude-work"), + displayName: "Work", + auth: { status: "authenticated", email: "b@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const hubAccount = (email: string) => ({ + id: `claude-${email}.json`, + driver: claude, + email, + usageLimits: { checkedAt, windows: [window] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native, named], + usageLimitSources: [ + { + ...source, + accounts: [ + hubAccount("a@example.com"), + hubAccount("b@example.com"), + hubAccount("c@example.com"), + ], + }, + ], + }, + }, + ], + ]); + expect( + collectLimitAccounts(input).map(({ displayName, email }) => ({ displayName, email })), + ).toEqual([ + { displayName: null, email: "a@example.com" }, + { displayName: "Work", email: "b@example.com" }, + { displayName: null, email: "c@example.com" }, + ]); + }); + it("pools Codex windows by kind, whichever slot reported them", () => { const codexAccount = (id: string, slots: readonly string[]) => ({ id, @@ -1138,6 +1189,28 @@ describe("/usage-limits", () => { expect(report?.accounts[0]?.limits.resetCredits?.availableCount).toBe(3); }); + it("labels hub rows by account id without .json", () => { + const hub = [ + { + ...sources[0]!, + label: "CLIProxyAPI", + accounts: [ + { + id: "claude-b@example.com.json", + driver: selected.driver, + email: "b@example.com", + usageLimits: limits, + }, + { id: "team-seat.json", driver: selected.driver, usageLimits: limits }, + ], + }, + ]; + const report = collectProviderUsageLimits(selected.instanceId, [selected], hub, now); + expect( + report?.accounts.filter((account) => account.sourceLabel).map((account) => account.label), + ).toEqual(["CLIProxyAPI · claude-b@example.com", "CLIProxyAPI · team-seat"]); + }); + it("keeps unidentified custom instances separate, filtering by driver", () => { const report = collectProviderUsageLimits( selected.instanceId, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index bf7dfea4e04bf..869f676acae69 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -9,6 +9,7 @@ import { type ProviderConsumeResetCreditInput, type ServerProviderSlashCommand, isProviderAvailable, + PROVIDER_DISPLAY_NAMES, type ServerProvider, type ServerProviderUsageLimits, type ServerProviderUsageWindow, @@ -232,6 +233,8 @@ function accountKeyResolver(reports: Iterable) { }; } +const hubAccountName = (account: { readonly id: string }) => account.id.replace(/\.json$/i, ""); + export interface LimitAccount { readonly key: string; readonly driver: ServerProvider["driver"]; @@ -297,9 +300,18 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ]; const winner = fresher ? next : previous; const creditSource = creditSources.get(key); + // A hub e-mail names the account better than the driver's default instance name. + const hubEmail = [previous, next].some( + (account) => account.environments.length === 0 && account.email, + ); + const displayName = previous.displayName ?? next.displayName; accounts.set(key, { ...previous, - displayName: previous.displayName ?? next.displayName, + displayName: + hubEmail && + displayName?.toLowerCase() === PROVIDER_DISPLAY_NAMES[previous.driver]?.toLowerCase() + ? null + : displayName, plan: planSources.get(key)?.plan ?? previous.plan ?? next.plan, accentColor: previous.accentColor ?? next.accentColor, environments, @@ -353,7 +365,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl merge(resolveKey(account) ?? key, { key, driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + displayName: account.email ? null : hubAccountName(account), email: account.email, plan: account.plan, accentColor: undefined, @@ -843,7 +855,7 @@ export function collectProviderUsageLimits( accounts.push({ id: `${source.id}:${account.id}`, driver: account.driver, - label: `${source.label} · ${account.id}`, + label: `${source.label} · ${hubAccountName(account)}`, sourceLabel: "CLI Proxy", ...(account.usageLimits.resetCredits?.nextCreditId ? { diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index a175c365d080e..3156efdd7d80f 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -47,6 +47,7 @@ export class PullRequestProviderError extends Schema.TaggedError; readonly authArgs: ReadonlyArray; + /** Asked once when `authArgs` reports signed out; its answer counts only when it finds a sign-in. */ + readonly authRetryArgs?: ReadonlyArray; readonly remoteRefinementArgs?: ( context: SourceControlProvider.SourceControlProviderContext, ) => ReadonlyArray; @@ -269,6 +271,37 @@ export function probeSourceControlProvider(input: { } const spec = input.spec; + const runAuth = (args: ReadonlyArray) => + input.process + .run({ + operation: "source-control.discovery.auth", + command: spec.executable, + args, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: probeTimeoutMs(spec), + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map(spec.parseAuth), + Effect.catch((cause) => + Effect.succeed(unknownAuth(Option.getOrUndefined(detailFromCause(cause)))), + ), + ); + const retryArgs = spec.authRetryArgs; + const auth = + retryArgs === undefined + ? runAuth(spec.authArgs) + : runAuth(spec.authArgs).pipe( + Effect.filterOrElse( + (result) => result.status !== "unauthenticated", + (signedOut) => + runAuth(retryArgs).pipe( + Effect.map((retry) => (retry.status === "authenticated" ? retry : signedOut)), + ), + ), + ); // The auth check does not depend on the version output, so both spawn at once. return Effect.all( @@ -278,23 +311,7 @@ export function probeSourceControlProvider(input: { process: input.process, cwd: input.cwd, }), - input.process - .run({ - operation: "source-control.discovery.auth", - command: spec.executable, - args: spec.authArgs, - cwd: input.cwd, - allowNonZeroExit: true, - timeoutMs: probeTimeoutMs(spec), - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map(spec.parseAuth), - Effect.catch((cause) => - Effect.succeed(unknownAuth(Option.getOrUndefined(detailFromCause(cause)))), - ), - ), + auth, ], { concurrency: "unbounded" }, ).pipe( diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index 936742b0659d0..7226faad651cf 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -5,7 +5,7 @@ import type { SourceControlSignInPrompt, SourceControlSignInResult, } from "@t3tools/contracts"; -import type * as Crypto from "effect/Crypto"; +import type * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; import type { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; @@ -65,6 +65,8 @@ export interface SourceControlDriverInstance { readonly signIn?: SourceControlSignIn; /** Whether the server's account on `host` starred `repository` (`owner/name`). */ readonly isRepositoryStarred?: (host: string, repository: string) => Effect.Effect; + /** Stars `repository` as the server's account on `host`; false when the forge refused. */ + readonly starRepository?: (host: string, repository: string) => Effect.Effect; } /** What the server gives every driver. A driver builds anything else it needs itself. */ @@ -73,7 +75,7 @@ export type SourceControlDriverServices = | SourceControlRateLimit.SourceControlRateLimit | HttpClient.HttpClient | FileSystem.FileSystem - | Crypto.Crypto; + | Digest.Digest; export interface SourceControlDriver { readonly kind: SourceControlProviderKind; diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts index 1499fbbb6dd3e..4a5299ec47af2 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts @@ -1,9 +1,15 @@ import { assert, describe, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; -import { ForgejoComment, forgejoComment } from "./forgejoPullRequestJson.ts"; +import { + ForgejoComment, + ForgejoReview, + forgejoComment, + forgejoReview, +} from "./forgejoPullRequestJson.ts"; const decodeForgejoComment = Schema.decodeSync(ForgejoComment); +const decodeForgejoReview = Schema.decodeSync(ForgejoReview); describe("forgejoComment", () => { 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 607e6ca00d1b1..db3aee2df2c4b 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"; @@ -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, ); diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index dc8ca69770e46..48f3021d1fe38 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -69,6 +69,44 @@ it.effect("maps credential verification failures without relabeling operation fa }), ); +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 a99aa87c9652d..b998d2be735a6 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -120,6 +120,15 @@ function gitHubProviderFailure( }; case "SourceControlRateLimitPausedError": return { reason: "rate-limited", retryAt: error.retryAt }; + 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 158a0982d96f2..0d48932db327b 100644 --- a/packages/source-control-github/src/server/GitHubRequestPolicy.ts +++ b/packages/source-control-github/src/server/GitHubRequestPolicy.ts @@ -85,6 +85,7 @@ export class GitHubApiResponseError extends Schema.TaggedError { - it.effect("is true only when GitHub answers 204 for the repository", () => { +describe("starred", () => { + it.effect("checks and stars only a valid repository, true only on 204", () => { const paths: Array = []; const starred = new Set(["user/starred/pingdotgg/t3code"]); const api = Layer.mock(GitHubRest.GitHubRest)({ rest: (input) => { - paths.push(input.path); + paths.push(`${input.method ?? "GET"} ${input.path}`); return starred.has(input.path) ? Effect.succeed({ status: 204, @@ -31,12 +31,20 @@ describe("makeIsRepositoryStarred", () => { }); return Effect.gen(function* () { const isStarred = yield* makeIsRepositoryStarred; + const star = yield* makeStarRepository; assert.isTrue(yield* isStarred("github.com", "pingdotgg/t3code")); assert.isFalse(yield* isStarred("github.com", "pingdotgg/uploadthing")); assert.isFalse(yield* isStarred("github.com", "pingdotgg/t3code/../../user")); + assert.isFalse(yield* isStarred("github.com", "../repos")); + assert.isTrue(yield* star("github.com", "pingdotgg/t3code")); + assert.isFalse(yield* star("github.com", "pingdotgg/uploadthing")); + assert.isFalse(yield* star("github.com", "pingdotgg/t3code/../../user")); + assert.isFalse(yield* star("github.com", "../repos")); assert.deepStrictEqual(paths, [ - "user/starred/pingdotgg/t3code", - "user/starred/pingdotgg/uploadthing", + "GET user/starred/pingdotgg/t3code", + "GET user/starred/pingdotgg/uploadthing", + "PUT user/starred/pingdotgg/t3code", + "PUT user/starred/pingdotgg/uploadthing", ]); }).pipe(Effect.provide(api)); }); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index 351933ce551ca..45af994eca26e 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -53,17 +53,25 @@ const makeSignIn = Effect.gen(function* () { } satisfies SourceControlSignIn; }); +const starredPath = (repository: string) => { + const segments = repository.split("/"); + // "." and ".." survive encoding and would normalize the URL out of user/starred. + return segments.length === 2 && segments.every((segment) => !["", ".", ".."].includes(segment)) + ? `user/starred/${segments.map(encodeURIComponent).join("/")}` + : undefined; +}; + // Only 204 means starred. Tokens without star access, a missing login, and 404 all read as false. export const makeIsRepositoryStarred = Effect.gen(function* () { const api = yield* GitHubRest.GitHubRest; return Effect.fn("GitHubDriver.isRepositoryStarred")( function* (host: string, repository: string) { - const segments = repository.split("/"); - if (segments.length !== 2) return false; + const path = starredPath(repository); + if (path === undefined) return false; const response = yield* api.rest({ host, operation: "isRepositoryStarred", - path: `user/starred/${segments.map(encodeURIComponent).join("/")}`, + path, allowReserve: false, }); return response.status === 204; @@ -72,6 +80,20 @@ export const makeIsRepositoryStarred = Effect.gen(function* () { ); }); +// False on any failure, such as a token or app sign-in without permission to star. +export const makeStarRepository = Effect.gen(function* () { + const api = yield* GitHubRest.GitHubRest; + return Effect.fn("GitHubDriver.starRepository")( + function* (host: string, repository: string) { + const path = starredPath(repository); + if (path === undefined) return false; + const response = yield* api.rest({ host, operation: "starRepository", method: "PUT", path }); + return response.status === 204; + }, + Effect.orElseSucceed(() => false), + ); +}); + const makeInstance = Effect.all({ sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, @@ -79,6 +101,7 @@ const makeInstance = Effect.all({ fetchMedia: GitHubMediaFetch.make, signIn: makeSignIn, isRepositoryStarred: makeIsRepositoryStarred, + starRepository: makeStarRepository, }); export const driver = defineSourceControlDriver({ diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts index 667dc4f8f4c22..769a8ce65f259 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts @@ -302,6 +302,7 @@ describe("call", () => { errors: [ { type: "NOT_FOUND", message: "Could not resolve" }, { type: "FORBIDDEN", message: "Resource not accessible" }, + { message: "Unknown error" }, ], }), ); @@ -310,7 +311,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 7030abb1742e2..484e808b0cd49 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -248,6 +248,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-github/src/server/githubStackActions.test.ts b/packages/source-control-github/src/server/githubStackActions.test.ts index 3d95638bc48e9..e6559d05195d4 100644 --- a/packages/source-control-github/src/server/githubStackActions.test.ts +++ b/packages/source-control-github/src/server/githubStackActions.test.ts @@ -341,7 +341,7 @@ it.effect("rebases the open layers bottom to top in a scratch clone, each onto t expect(api.calls.some(isMutation)).toBe(false); const rebases = gitCalls.filter((args) => args[0] === "rebase"); expect(rebases).toEqual([ - ["rebase", "--quiet", "--onto", "origin/main", "new-sha"], + ["rebase", "--quiet", "--onto", "origin/main", "aaa"], ["rebase", "--quiet", "--onto", "new-sha", "bbb"], ]); expect(gitCalls.filter((args) => args[0] === "push")).toEqual([ diff --git a/packages/source-control-github/src/server/githubStackActions.ts b/packages/source-control-github/src/server/githubStackActions.ts index f3997a45642a1..fb524ae0518de 100644 --- a/packages/source-control-github/src/server/githubStackActions.ts +++ b/packages/source-control-github/src/server/githubStackActions.ts @@ -201,6 +201,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* }) ) return yield* new GitHubStackPermissionError({ ...identity }); + const mergedHeadSha = stack.layers[stack.layers.indexOf(open[0]!) - 1]?.headSha; // GitHub has no stack-rebase API; per-PR updates replay lower layers' old commits. yield* cascadeRebaseStack({ host: input.host, @@ -211,6 +212,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* headBranch: layer.headBranch, headSha: layer.headSha!, })), + ...(mergedHeadSha === undefined ? {} : { mergedHeadSha }), }).pipe( Effect.mapError((cause) => cause._tag === "GitHubStackRebaseConflictError" || diff --git a/packages/source-control-github/src/server/githubStackRebase.test.ts b/packages/source-control-github/src/server/githubStackRebase.test.ts index aa04c61744753..8bf4662a49e8e 100644 --- a/packages/source-control-github/src/server/githubStackRebase.test.ts +++ b/packages/source-control-github/src/server/githubStackRebase.test.ts @@ -92,6 +92,59 @@ it.layer(layer)("cascadeRebaseStack", (it) => { }).pipe(Effect.scoped), ); + it.effect("drops a squash-merged lower layer's commits from the layer above it", () => + Effect.gen(function* () { + const repo = yield* setup; + const fs = yield* FileSystem.FileSystem; + const { process } = yield* SourceControlHost.SourceControlHost; + const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-squash-" }); + const run = (...args: string[]) => + process + .run({ + operation: "test", + command: "git", + args, + cwd: scratch, + env: { + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@t", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@t", + }, + }) + .pipe(Effect.map((out) => out.stdout.trim())); + const write = (text: string) => fs.writeFileString(`${scratch}/index.ts`, text); + yield* run("clone", "--quiet", repo.remote, "."); + yield* run("switch", "--quiet", "-c", "feat/two-steps"); + yield* write("export const answer = 40;\n"); + yield* run("commit", "--quiet", "-am", "A1"); + yield* write("export const answer = 42;\n"); + yield* run("commit", "--quiet", "-am", "A2"); + const mergedHeadSha = yield* run("rev-parse", "HEAD"); + yield* run("switch", "--quiet", "-c", "feat/on-top"); + yield* write("/** The answer. */\nexport const answer = 42;\n"); + yield* run("commit", "--quiet", "-am", "B"); + const headSha = yield* run("rev-parse", "HEAD"); + yield* run("switch", "--quiet", "main"); + yield* run("merge", "--quiet", "--squash", "feat/two-steps"); + yield* run("commit", "--quiet", "-m", "A (#1)"); + yield* run("push", "--quiet", "origin", "main", "feat/on-top"); + + const completed = yield* cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: [{ number: 2, headBranch: "feat/on-top", headSha }], + mergedHeadSha, + remote: repo.remote, + }); + assert.strictEqual(completed, 1); + const top = yield* repo.head("feat/on-top"); + expect(yield* repo.git("rev-parse", `${top}~1`)).toBe(yield* repo.head("main")); + expect(yield* repo.git("log", "--format=%s", `main..${top}`)).toBe("B"); + }).pipe(Effect.scoped), + ); + it.effect("refuses to overwrite a layer pushed after it was reviewed", () => Effect.gen(function* () { const repo = yield* setup; diff --git a/packages/source-control-github/src/server/githubStackRebase.ts b/packages/source-control-github/src/server/githubStackRebase.ts index 27595e7e7706d..e75b5e880b040 100644 --- a/packages/source-control-github/src/server/githubStackRebase.ts +++ b/packages/source-control-github/src/server/githubStackRebase.ts @@ -40,6 +40,8 @@ export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (inp readonly repository: string; readonly base: string; readonly layers: ReadonlyArray; + /** Last head of the merged layer directly below the bottom open layer. */ + readonly mergedHeadSha?: string; readonly remote?: string; }) { const appAccess = yield* GitHubAppAccess.GitHubAppAccess; @@ -103,13 +105,22 @@ export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (inp let parentOld = `origin/${input.base}`; let parentNew = `origin/${input.base}`; + // A squash or rebase merge leaves the merged layer's commits outside the base's history. + const mergedParent = + input.mergedHeadSha !== undefined && + (yield* git(["merge-base", "--is-ancestor", input.mergedHeadSha, first.headSha], true).pipe( + Effect.mapError(failed("reading the fork point", first.number, 0)), + )).exitCode === 0 + ? input.mergedHeadSha + : undefined; for (const [index, layer] of input.layers.entries()) { - // Exclude old parents: the base fork point for the bottom, the reviewed lower head above it. + // Exclude old parents: the merged layer or fork point for the bottom, the reviewed head above. const upstream = index === 0 - ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( + ? (mergedParent ?? + (yield* git(["merge-base", parentOld, layer.headSha]).pipe( Effect.mapError(failed("reading the fork point", layer.number, index)), - )).stdout.trim() + )).stdout.trim()) : parentOld; yield* git(["checkout", "--quiet", "--detach", layer.headSha]).pipe( Effect.mapError(failed("checking out", layer.number, index)), diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index beeb6b38af1d0..21c499cae9ed1 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -146,7 +146,11 @@ export const discovery = { label: "GitLab", executable: "glab", versionArgs: ["--version"], + // Without --all, glab 1.79+ checks only the current host unless that is gitlab.com; older + // glab lists every host and rejects --all. --all waits on every host, so ask it only after + // the current host reports signed out. authArgs: ["auth", "status"], + authRetryArgs: ["auth", "status", "--all"], // glab host matching requires the remote's exact spelling. remoteRefinementArgs: ({ provider, remoteUrl }) => { const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/]+)/iu.exec(remoteUrl.trim())?.[1]; diff --git a/packages/source-control-gitlab/src/server/gitLabDiscovery.test.ts b/packages/source-control-gitlab/src/server/gitLabDiscovery.test.ts index a30f676c9c954..f0fdcba5aa065 100644 --- a/packages/source-control-gitlab/src/server/gitLabDiscovery.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabDiscovery.test.ts @@ -2,7 +2,10 @@ import { parseGitRemoteHost, unknownRemoteProvider, } from "@t3tools/source-control-core/shared/definition"; -import { refineUnknownRemoteProvider } from "@t3tools/source-control-core/server/discovery"; +import { + probeSourceControlProvider, + refineUnknownRemoteProvider, +} from "@t3tools/source-control-core/server/discovery"; import { processOutput } from "@t3tools/source-control-testing/TestSourceControlHost"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -21,6 +24,89 @@ Logged in to gitlab.com as gitlab-user assert.deepStrictEqual(auth.account, Option.some("gitlab-user")); }); +// Without `--all`, glab 1.79+ checks only the current host unless that is gitlab.com. +it.effect("reports a signed-in host when glab's default host is signed out", () => + Effect.gen(function* () { + const defaultHostOnly = `gitlab.gnome.org + x gitlab.gnome.org: API call failed: GET https://gitlab.gnome.org/api/v4/user: 401 {message: 401 Unauthorized} +`; + const allHosts = `gitlab.com + ✓ Logged in to gitlab.com as gitlab-user (/home/user/.config/glab-cli/config.yml) +${defaultHostOnly}`; + const item = yield* probeSourceControlProvider({ + spec: GitLabSourceControlProvider.discovery, + cwd: "/repo", + process: { + run: ({ args }) => + Effect.succeed( + args[0] === "--version" + ? processOutput("glab 1.124.0\n") + : processOutput(args.includes("--all") ? allHosts : defaultHostOnly, { + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ), + }, + }); + assert.strictEqual(item.auth.status, "authenticated"); + assert.deepStrictEqual(item.auth.account, Option.some("gitlab-user")); + assert.deepStrictEqual(item.auth.host, Option.some("gitlab.com")); + }), +); + +it.effect("skips --all when the current glab host is signed in", () => + Effect.gen(function* () { + const asked: Array> = []; + const item = yield* probeSourceControlProvider({ + spec: GitLabSourceControlProvider.discovery, + cwd: "/repo", + process: { + run: ({ args }) => { + asked.push(args); + return Effect.succeed( + args[0] === "--version" + ? processOutput("glab 1.124.0\n") + : processOutput(`gitlab.example.test + ✓ Logged in to gitlab.example.test as gitlab-user +`), + ); + }, + }, + }); + assert.strictEqual(item.auth.status, "authenticated"); + assert.isFalse(asked.some((args) => args.includes("--all"))); + }), +); + +it.effect("keeps glab's own sign-out detail when glab predates --all", () => + Effect.gen(function* () { + const item = yield* probeSourceControlProvider({ + spec: GitLabSourceControlProvider.discovery, + cwd: "/repo", + process: { + run: ({ args }) => + Effect.succeed( + args[0] === "--version" + ? processOutput("glab 1.53.0\n") + : args.includes("--all") + ? processOutput("", { + stderr: "unknown flag: --all\n", + exitCode: ChildProcessSpawner.ExitCode(1), + }) + : processOutput("", { + stderr: "No GitLab instances have been authenticated with glab.\n", + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ), + }, + }); + assert.strictEqual(item.auth.status, "unauthenticated"); + assert.deepStrictEqual( + item.auth.detail, + Option.some("No GitLab instances have been authenticated with glab."), + ); + }), +); + it.effect.each([ { name: "without relying on host naming", 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 6cea42d131a6d..b0ff65cba3af3 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"; @@ -76,7 +77,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; @@ -119,7 +120,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); @@ -149,7 +153,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"); @@ -175,6 +182,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), @@ -232,6 +240,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), @@ -296,6 +305,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), @@ -400,6 +410,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 d2ee6b39a9a2c..6a08b20a4256a 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"; @@ -86,7 +86,7 @@ interface SshTunnelEntry { type SshEnvironmentEffectContext = | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient @@ -867,7 +867,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", { @@ -929,7 +929,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", { @@ -981,7 +981,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", { @@ -1007,7 +1007,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"], @@ -1110,7 +1110,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 @@ -1555,7 +1555,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( @@ -1595,7 +1595,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), ); @@ -1778,7 +1778,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/patches/expo-audio@58.0.9.patch b/patches/expo-audio@58.0.9.patch index 8a644111bce49..8249a399b3bad 100644 --- a/patches/expo-audio@58.0.9.patch +++ b/patches/expo-audio@58.0.9.patch @@ -72,9 +72,10 @@ diff --git a/ios/AudioModule.swift b/ios/AudioModule.swift !self.isSessionInUse else { return diff --git a/src/AudioModule.types.ts b/src/AudioModule.types.ts +index 3194a8ebd1b91793d03a1692c563b5d2e83c2738..e7e713199f776b60093b75da782197427cf2000d 100644 --- a/src/AudioModule.types.ts +++ b/src/AudioModule.types.ts -@@ -27,6 +27,8 @@ +@@ -24,6 +24,8 @@ import type { AudioStream } from './AudioStream.types'; */ export declare class NativeAudioModule extends NativeModule { setIsAudioActiveAsync(active: boolean): Promise; @@ -83,7 +84,7 @@ diff --git a/src/AudioModule.types.ts b/src/AudioModule.types.ts setAudioModeAsync(category: Partial): Promise; requestRecordingPermissionsAsync(): Promise; requestNotificationPermissionsAsync(): Promise; -@@ -175,8 +175,9 @@ +@@ -175,8 +177,9 @@ export declare class AudioPlayer extends SharedObject { /** * Pauses the player. @@ -95,9 +96,10 @@ diff --git a/src/AudioModule.types.ts b/src/AudioModule.types.ts /** * Replaces the current audio source with a new one. diff --git a/build/AudioModule.types.d.ts b/build/AudioModule.types.d.ts +index 0d63c9b949cfae7a101902fc3bb70fcf46094f68..b590f9654bbf74b5052b71e31b7ed6d3d0050aa4 100644 --- a/build/AudioModule.types.d.ts +++ b/build/AudioModule.types.d.ts -@@ -6,6 +6,8 @@ +@@ -8,6 +8,8 @@ import type { AudioStream } from './AudioStream.types'; */ export declare class NativeAudioModule extends NativeModule { setIsAudioActiveAsync(active: boolean): Promise; @@ -106,7 +108,7 @@ diff --git a/build/AudioModule.types.d.ts b/build/AudioModule.types.d.ts setAudioModeAsync(category: Partial): Promise; requestRecordingPermissionsAsync(): Promise; requestNotificationPermissionsAsync(): Promise; -@@ -135,8 +135,9 @@ +@@ -135,8 +137,9 @@ export declare class AudioPlayer extends SharedObject { play(): void; /** * Pauses the player. @@ -118,12 +120,367 @@ diff --git a/build/AudioModule.types.d.ts b/build/AudioModule.types.d.ts * Replaces the current audio source with a new one. */ diff --git a/android/src/main/java/expo/modules/audio/AudioModule.kt b/android/src/main/java/expo/modules/audio/AudioModule.kt +index 6c81fce3dd518d3b82e3280940a001c7be84dd0a..16125c679c870e2eae48f8db953dedd3fc842c3d 100644 --- a/android/src/main/java/expo/modules/audio/AudioModule.kt +++ b/android/src/main/java/expo/modules/audio/AudioModule.kt -@@ -678,5 +678,5 @@ +@@ -677,7 +677,7 @@ class AudioModule : Module() { + } } - Function("pause") { player: AudioPlayer -> + Function("pause") { player: AudioPlayer, /* deactivateSession - iOS only */ _: Boolean? -> runOnMain { player.isPaused = false + player.ref.pause() +@@ -824,10 +824,14 @@ class AudioModule : Module() { + return@Function recorder.getAvailableInputs(audioManager) + } + +- Function("setInput") { recorder: AudioRecorder, input: String -> ++ AsyncFunction("setInput") Coroutine { recorder: AudioRecorder, input: String -> + recorder.setInput(input, audioManager) + } + ++ Function("cancelInputSelection") { recorder: AudioRecorder -> ++ recorder.cancelInputSelection() ++ } ++ + Function("startRecordingAtTime") { recorder: AudioRecorder, seconds: Double -> + checkRecordingPermission() + if (recorder.isPrepared) { +diff --git a/android/src/main/java/expo/modules/audio/AudioRecorder.kt b/android/src/main/java/expo/modules/audio/AudioRecorder.kt +index 2ff4a474fdea3efa652436a8a3960c3669ad298b..1ced279991294d3d81cb20fa339ca73e5d6d6c01 100644 +--- a/android/src/main/java/expo/modules/audio/AudioRecorder.kt ++++ b/android/src/main/java/expo/modules/audio/AudioRecorder.kt +@@ -19,6 +19,9 @@ import expo.modules.kotlin.exception.CodedException + import expo.modules.kotlin.exception.Exceptions + import expo.modules.kotlin.sharedobjects.SharedObject + import kotlinx.coroutines.Job ++import kotlinx.coroutines.CancellationException ++import kotlinx.coroutines.currentCoroutineContext ++import kotlinx.coroutines.job + import kotlinx.coroutines.delay + import kotlinx.coroutines.launch + import java.io.File +@@ -42,12 +45,19 @@ class AudioRecorder( + var isPrepared = false + + private var recorder: MediaRecorder? = null ++ private val inputRoute = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { ++ RecordingInputRoute(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager) ++ } else { ++ null ++ } + val id = UUID.randomUUID().toString() + var startTime = 0L + var isRecording = false + var isPaused = false + private var isPausedBySystem = false + private var recordingTimerJob: Job? = null ++ private var inputSelectionJob: Job? = null ++ private var inputSelectionCanceled = false + private var durationLimitMillis: Long? = null + var useForegroundService = false + +@@ -90,6 +100,7 @@ class AudioRecorder( + throw AudioRecorderAlreadyPreparedException() + } + ++ synchronized(this) { inputSelectionCanceled = false } + val recordingOptions = options ?: this.options + val mediaRecorder = createRecorder(recordingOptions) + recorder = mediaRecorder +@@ -245,10 +256,15 @@ class AudioRecorder( + } + + private fun reset() { ++ cancelInputSelection() + recordingTimerJob?.cancel() + recordingTimerJob = null + +- recorder?.release() ++ try { ++ recorder?.release() ++ } finally { ++ inputRoute?.close() ++ } + recorder = null + isRecording = false + isPaused = false +@@ -468,55 +484,68 @@ class AudioRecorder( + throw DeviceInfoNotFoundException() + } + +- return getMapFromDeviceInfo(deviceInfo) ++ return getMapFromDeviceInfo(deviceInfo, recordingInputs(audioManager)) + } + + private fun hasRecordingPermissions() = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + +- fun getAvailableInputs(audioManager: AudioManager) = +- audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).mapNotNull { deviceInfo -> +- val type = deviceInfo.type +- if (type == AudioDeviceInfo.TYPE_BUILTIN_MIC || type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || type == AudioDeviceInfo.TYPE_WIRED_HEADSET) { +- getMapFromDeviceInfo(deviceInfo) +- } else { +- null +- } ++ fun getAvailableInputs(audioManager: AudioManager): List { ++ val inputs = recordingInputs(audioManager) ++ return inputs.map { getMapFromDeviceInfo(it, inputs) } ++ } ++ ++ suspend fun setInput(uid: String, audioManager: AudioManager) { ++ val job = currentCoroutineContext().job ++ synchronized(this) { ++ if (inputSelectionCanceled) throw CancellationException("Input selection canceled") ++ inputSelectionJob = job ++ } ++ try { ++ setInputDevice(uid, audioManager) ++ } finally { ++ synchronized(this) { if (inputSelectionJob === job) inputSelectionJob = null } + } ++ } ++ ++ fun cancelInputSelection() { ++ val job = synchronized(this) { ++ inputSelectionCanceled = true ++ inputSelectionJob ++ } ++ job?.cancel() ++ } + + @Suppress("DEPRECATION") +- fun setInput(uid: String, audioManager: AudioManager) { +- val deviceInfo: AudioDeviceInfo? = getDeviceInfoFromUid(uid, audioManager) ++ private suspend fun setInputDevice(uid: String, audioManager: AudioManager) { ++ val deviceInfo = getDeviceInfoFromUid(uid, audioManager) ?: throw PreferredInputNotFoundException() + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + throw SetAudioInputNotSupportedException() + } + +- if (deviceInfo != null && deviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) { +- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { +- audioManager.setCommunicationDevice(deviceInfo) +- } else { ++ try { ++ if (inputRoute != null) { ++ inputRoute.select(deviceInfo) ++ } else if (deviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) { + audioManager.startBluetoothSco() +- } +- } else { +- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { +- audioManager.clearCommunicationDevice() + } else { + audioManager.stopBluetoothSco() + } +- } +- +- val success = recorder?.setPreferredDevice(deviceInfo) +- if (success == false) { +- throw PreferredInputNotFoundException() ++ val source = getDeviceInfoFromUid(uid, audioManager)?.takeIf { it.isSource } ++ if (recorder?.setPreferredDevice(source) != true) { ++ throw PreferredInputNotFoundException() ++ } ++ } catch (error: Exception) { ++ inputRoute?.close() ++ throw error + } + } + + private fun getDeviceInfoFromUid(uid: String, audioManager: AudioManager): AudioDeviceInfo? { +- val id = uid.toInt() +- val audioDevices: Array = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) ++ val audioDevices = recordingInputs(audioManager) + for (device in audioDevices) { +- if (device.id == id) { ++ if (recordingInputUid(device, audioDevices) == uid) { + return device + } + } +diff --git a/android/src/main/java/expo/modules/audio/AudioUtils.kt b/android/src/main/java/expo/modules/audio/AudioUtils.kt +index 81819c1d771bdeec093bb35a65a5e1e1af0cf9d7..cd0c7624b4cd8a4ebf7ed3b3390cfc76802e8530 100644 +--- a/android/src/main/java/expo/modules/audio/AudioUtils.kt ++++ b/android/src/main/java/expo/modules/audio/AudioUtils.kt +@@ -2,6 +2,8 @@ package expo.modules.audio + + import android.content.Context + import android.media.AudioDeviceInfo ++import android.media.AudioManager ++import android.os.Build + import android.os.Bundle + import androidx.annotation.OptIn + import androidx.media3.common.Player +@@ -17,22 +19,57 @@ fun ensureDirExists(dir: File): File { + return dir + } + +-fun getMapFromDeviceInfo(deviceInfo: AudioDeviceInfo): Bundle { ++fun getMapFromDeviceInfo(deviceInfo: AudioDeviceInfo, inputs: List): Bundle { + val map = Bundle() +- val type = when (deviceInfo.type) { +- AudioDeviceInfo.TYPE_BUILTIN_MIC -> "MicrophoneBuiltIn" +- AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothSCO" +- AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BluetoothA2DP" +- AudioDeviceInfo.TYPE_TELEPHONY -> "Telephony" +- AudioDeviceInfo.TYPE_WIRED_HEADSET -> "MicrophoneWired" +- else -> "Unknown device type" +- } ++ val type = recordingInputType(deviceInfo.type) ?: "Unknown device type" + map.putString("name", deviceInfo.productName.toString()) + map.putString("type", type) +- map.putString("uid", deviceInfo.id.toString()) ++ map.putString("uid", recordingInputUid(deviceInfo, inputs)) + return map + } + ++internal fun recordingInputs(manager: AudioManager): List { ++ val inputs = manager.getDevices(AudioManager.GET_DEVICES_INPUTS).filter { ++ recordingInputType(it.type) != null ++ } ++ val bluetooth = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { ++ manager.availableCommunicationDevices.filter { isBluetoothInput(it.type) } ++ } else { ++ emptyList() ++ } ++ // Some Bluetooth microphone ports appear only after the communication route is activated. ++ return (inputs + bluetooth).distinctBy { ++ if (isBluetoothInput(it.type)) recordingInputUid(it) else "port:${it.id}" ++ } ++} ++ ++internal fun isBluetoothInput(type: Int) = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || ++ type == AudioDeviceInfo.TYPE_BLE_HEADSET ++ ++internal fun recordingInputType(type: Int): String? = when (type) { ++ AudioDeviceInfo.TYPE_BUILTIN_MIC -> "MicrophoneBuiltIn" ++ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothSCO" ++ AudioDeviceInfo.TYPE_BLE_HEADSET -> "BluetoothLE" ++ AudioDeviceInfo.TYPE_WIRED_HEADSET -> "MicrophoneWired" ++ AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET -> "USBAudio" ++ AudioDeviceInfo.TYPE_LINE_ANALOG, AudioDeviceInfo.TYPE_LINE_DIGITAL -> "LineIn" ++ else -> null ++} ++ ++internal fun recordingInputUid(device: AudioDeviceInfo, inputs: List = emptyList()): String { ++ val base = recordingInputBaseUid(device) ++ val ambiguous = !isBluetoothInput(device.type) && inputs.any { ++ it.id != device.id && recordingInputBaseUid(it) == base ++ } ++ return if (ambiguous) "$base:port:${device.id}" else base ++} ++ ++private fun recordingInputBaseUid(device: AudioDeviceInfo): String { ++ // Port IDs and USB addresses (ALSA card numbers) change on reconnect; Bluetooth addresses don't. ++ val stable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isBluetoothInput(device.type) ++ return "android:${device.type}:${if (stable) device.address.ifBlank { device.productName } else device.productName}" ++} ++ + @OptIn(UnstableApi::class) + fun buildBasicMediaSession(context: Context, player: Player, onPlay: () -> Unit): MediaSession { + return MediaSession.Builder(context, PlayRequestingPlayer(player, onPlay)) +diff --git a/android/src/main/java/expo/modules/audio/RecordingInputRoute.kt b/android/src/main/java/expo/modules/audio/RecordingInputRoute.kt +new file mode 100644 +index 0000000000000000000000000000000000000000..cdba9724318cf06afd21b4df6b895b381afa8e6f +--- /dev/null ++++ b/android/src/main/java/expo/modules/audio/RecordingInputRoute.kt +@@ -0,0 +1,53 @@ ++package expo.modules.audio ++ ++import android.media.AudioDeviceInfo ++import android.media.AudioManager ++import androidx.annotation.RequiresApi ++import java.io.Closeable ++import java.util.concurrent.Executor ++import kotlinx.coroutines.CompletableDeferred ++import kotlinx.coroutines.withTimeout ++ ++@RequiresApi(31) ++internal class RecordingInputRoute(private val manager: AudioManager) : Closeable { ++ private var previousMode: Int? = null ++ ++ suspend fun select(input: AudioDeviceInfo) { ++ if (!isBluetoothInput(input.type)) { ++ close() ++ return ++ } ++ // Communication routing takes the headset's output; Android pairs its microphone. ++ val output = manager.availableCommunicationDevices.firstOrNull { ++ it.isSink && it.type == input.type && ++ if (input.address.isNotBlank()) it.address == input.address else it.productName == input.productName ++ } ?: throw PreferredInputNotFoundException() ++ val ready = CompletableDeferred() ++ val listener = AudioManager.OnCommunicationDeviceChangedListener { ++ if (it?.id == output.id) ready.complete(Unit) ++ } ++ manager.addOnCommunicationDeviceChangedListener(Executor { it.run() }, listener) ++ try { ++ if (previousMode == null) previousMode = manager.mode ++ manager.mode = AudioManager.MODE_IN_COMMUNICATION ++ if (!manager.setCommunicationDevice(output)) throw PreferredInputNotFoundException() ++ if (manager.communicationDevice?.id == output.id) ready.complete(Unit) ++ withTimeout(5_000) { ready.await() } ++ } catch (error: Exception) { ++ close() ++ throw error ++ } finally { ++ manager.removeOnCommunicationDeviceChangedListener(listener) ++ } ++ } ++ ++ override fun close() { ++ val mode = previousMode ?: return ++ previousMode = null ++ try { ++ manager.clearCommunicationDevice() ++ } finally { ++ if (manager.mode == AudioManager.MODE_IN_COMMUNICATION) manager.mode = mode ++ } ++ } ++} +diff --git a/android/src/test/java/expo/modules/audio/RecordingInputIdentityTest.kt b/android/src/test/java/expo/modules/audio/RecordingInputIdentityTest.kt +new file mode 100644 +index 0000000000000000000000000000000000000000..4dd0567e2170770e9b0f6a17dc4581bc769d5c6a +--- /dev/null ++++ b/android/src/test/java/expo/modules/audio/RecordingInputIdentityTest.kt +@@ -0,0 +1,35 @@ ++package expo.modules.audio ++ ++import android.media.AudioDeviceInfo ++import android.media.AudioManager ++import io.mockk.every ++import io.mockk.mockk ++import org.junit.Assert.* ++import org.junit.Test ++ ++class RecordingInputIdentityTest { ++ private fun device(id: Int) = mockk { ++ every { this@mockk.id } returns id ++ every { type } returns AudioDeviceInfo.TYPE_USB_DEVICE ++ every { productName } returns "USB Microphone" ++ every { address } returns "card=$id;device=0;" ++ } ++ ++ @Test ++ fun identicalUsbMicrophonesRemainSelectable() { ++ val first = device(1) ++ val second = device(2) ++ val manager = mockk { ++ every { getDevices(AudioManager.GET_DEVICES_INPUTS) } returns arrayOf(first, second) ++ } ++ val inputs = recordingInputs(manager) ++ assertEquals(2, inputs.size) ++ val firstUid = recordingInputUid(first, inputs) ++ val secondUid = recordingInputUid(second, inputs) ++ assertNotEquals(firstUid, secondUid) ++ assertSame(first, inputs.single { recordingInputUid(it, inputs) == firstUid }) ++ assertSame(second, inputs.single { recordingInputUid(it, inputs) == secondUid }) ++ val reconnected = device(99) ++ assertEquals(recordingInputUid(first, listOf(first)), recordingInputUid(reconnected, listOf(reconnected))) ++ } ++} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccc69351000ec..84d25ab15249a 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,10 +376,9 @@ 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 + expo-audio@58.0.9: b391b150cf67acdf23a9d924b791a63f8c4c84ec7a7b18cc82f815f7933fe4ba expo-modules-core@58.0.22: f0b0fd100550ed76e5c25b4d13eb7c9ff62825f8f197c53edf7bf447c50c9fb7 expo-network@58.0.3: cd09b6d1744dde4efa081b5619ebcff38877d98f7502a51cab121629dece8237 expo-sharing@58.0.19: 321a86e1471e34c53f1f2937fbb4304ea3f7b7a78ad9e4d35febc2b9e73e9688 @@ -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) @@ -623,7 +627,7 @@ importers: version: 58.0.16(@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) expo-audio: specifier: 58.0.9 - version: 58.0.9(patch_hash=530af071088a809fe1a97ce2b2269c78335fbb5dcd3893d6937ff5c304c85a0a)(expo-asset@58.0.16(@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))(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) + version: 58.0.9(patch_hash=b391b150cf67acdf23a9d924b791a63f8c4c84ec7a7b18cc82f815f7933fe4ba)(expo-asset@58.0.16(@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))(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) expo-auth-session: specifier: ~58.0.13 version: 58.0.13(@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) @@ -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,17 +834,17 @@ 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) '@cursor/sdk': 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) @@ -868,6 +872,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 @@ -908,8 +915,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 @@ -949,10 +956,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 @@ -1050,8 +1057,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 @@ -1136,6 +1143,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.31.3 version: 3.31.3 + canvas-confetti: + specifier: 1.9.4 + version: 1.9.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1146,8 +1156,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 @@ -1249,17 +1259,20 @@ 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)) '@tanstack/router-plugin': specifier: ^1.161.0 version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.3.0(react@19.3.0))(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))(supports-color@8.1.1) + '@types/canvas-confetti': + specifier: 1.9.0 + version: 1.9.0 '@types/compression': specifier: ^1.8.1 version: 1.8.1 @@ -1318,8 +1331,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 @@ -1330,12 +1343,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 @@ -1350,28 +1363,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 @@ -1388,24 +1401,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 @@ -1429,16 +1445,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 @@ -1447,11 +1463,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 @@ -1468,15 +1484,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) @@ -1504,6 +1520,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 @@ -1523,8 +1542,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 @@ -1533,8 +1552,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 @@ -1545,12 +1564,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) @@ -1558,18 +1577,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 @@ -1580,18 +1599,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 @@ -1614,15 +1633,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 @@ -1651,15 +1670,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 @@ -1682,15 +1701,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 @@ -1716,18 +1735,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 @@ -1753,15 +1772,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 @@ -1787,15 +1806,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 @@ -1809,6 +1828,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': @@ -1824,15 +1874,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 @@ -1850,7 +1900,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 @@ -1867,15 +1917,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 @@ -1898,15 +1948,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 @@ -1920,8 +1970,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 @@ -1932,8 +1982,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 @@ -1945,15 +1995,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 @@ -1985,15 +2035,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 @@ -2013,8 +2063,8 @@ importers: specifier: workspace:* version: link:../contracts 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 @@ -2065,11 +2115,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 @@ -2098,15 +2148,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 @@ -2132,15 +2182,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 @@ -2160,12 +2210,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 @@ -2212,15 +2262,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 @@ -2246,12 +2296,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 @@ -2277,18 +2327,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 @@ -2311,12 +2361,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 @@ -2330,8 +2380,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 @@ -2342,8 +2392,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 @@ -2358,15 +2408,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 @@ -2380,12 +2430,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 @@ -2396,8 +2446,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 @@ -2417,8 +2467,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 @@ -2433,8 +2483,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 @@ -2478,25 +2528,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: @@ -2509,16 +2559,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 @@ -2531,27 +2581,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 @@ -2564,14 +2614,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: @@ -2592,14 +2642,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: @@ -2614,8 +2664,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' @@ -3383,32 +3453,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] @@ -3563,40 +3633,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==} @@ -3629,64 +3704,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==} @@ -5156,7 +5231,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: @@ -6939,6 +7014,9 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -7542,16 +7620,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': @@ -7560,8 +7637,6 @@ packages: optional: true '@effect/platform-node': optional: true - '@effect/vitest': - optional: true vite: optional: true ws: @@ -7881,6 +7956,9 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + capnp-es@0.0.16: resolution: {integrity: sha512-pqhhnRqGfDdgHa+rz4JVO9j1jnkyqsHRYrD4RBtIwxCknYSRG7Mjs+x3IRfd20u+ZGS0IhW2L7cDv9ZcIkCYhQ==} hasBin: true @@ -8367,7 +8445,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' @@ -8398,7 +8476,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 @@ -8525,8 +8603,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==} @@ -12261,8 +12339,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 @@ -12460,35 +12538,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: @@ -12496,24 +12575,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' @@ -12525,40 +12607,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: @@ -12571,15 +12655,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) @@ -13412,25 +13508,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': {} @@ -13541,39 +13637,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.18(effect@4.1.2) + effect: 4.1.2 + + '@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/aws4signer@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/axiom@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/cloudflare@1.0.0-rc.17(effect@4.0.5)': + '@distilled.cloud/cloudflare@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/core@1.0.0-rc.18(effect@4.1.2)': dependencies: - effect: 4.0.5 + effect: 4.1.2 - '@distilled.cloud/iceberg@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.17(effect@4.0.5)': + '@distilled.cloud/planetscale@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 '@dnd-kit/accessibility@3.1.1(react@19.3.0)': dependencies: @@ -13609,65 +13711,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/sql-pg@4.0.5(effect@4.0.5)': - dependencies: - effect: 4.0.5 + effect: 4.1.2 - '@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5)': + '@effect/sql-pg@4.1.2(effect@4.1.2)': 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' @@ -15121,22 +15226,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: @@ -16481,6 +16586,8 @@ snapshots: '@types/node': 24.12.4 '@types/responselike': 1.0.3 + '@types/canvas-confetti@1.9.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -17128,12 +17235,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 @@ -17141,8 +17248,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: @@ -17642,6 +17749,8 @@ snapshots: caniuse-lite@1.0.30001810: {} + canvas-confetti@1.9.4: {} + capnp-es@0.0.16(@tsc-rs/typescript@0.8.0): optionalDependencies: typescript: '@tsc-rs/typescript@0.8.0' @@ -18090,18 +18199,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 @@ -18123,7 +18231,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.5: {} + effect@4.1.2: {} ejs@3.1.10: dependencies: @@ -18400,7 +18508,7 @@ snapshots: - supports-color - typescript - expo-audio@58.0.9(patch_hash=530af071088a809fe1a97ce2b2269c78335fbb5dcd3893d6937ff5c304c85a0a)(expo-asset@58.0.16(@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))(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): + expo-audio@58.0.9(patch_hash=b391b150cf67acdf23a9d924b791a63f8c4c84ec7a7b18cc82f815f7933fe4ba)(expo-asset@58.0.16(@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))(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): dependencies: expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) expo-asset: 58.0.16(@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) @@ -22755,13 +22863,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 e2865566986d9..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 @@ -233,6 +236,7 @@ minimumReleaseAgeExclude: - "@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 @@ -303,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:" @@ -367,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 5de3c068fc6c6..6756f6fea855d 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2890,6 +2890,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 eaba712f4c516..17c9b2b4bb2ee 100644 --- a/scripts/check-changed.test.ts +++ b/scripts/check-changed.test.ts @@ -397,7 +397,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); @@ -407,6 +407,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 0bf96fb98ca3f..274ad9dacd314 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -172,7 +172,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[/\\]/; @@ -469,7 +470,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 6e7ef3c77ca61..ea7ec1e6c4702 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -20,6 +20,14 @@ set -eu repo="pingdotgg/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 51d074fffba61..910f780fe1681 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 8f300533523ae..a586d67ef6035 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/secret-provider-core/package.json",