diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 15bbbda6c6df..551aa17e962e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,9 +27,11 @@ "electron": "44.4.5", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", + "electron-webauthn": "catalog:", "ffi-rs": "1.3.2", "playwright-core": "1.60.0", - "react-grab": "^0.1.32" + "react-grab": "^0.1.32", + "tldts": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 08318bcd8da5..a6906099dbd5 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -17,6 +17,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; @@ -34,6 +35,7 @@ import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -276,13 +278,16 @@ const startup = Effect.gen(function* () { const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; + const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; yield* shellEnvironment.installIntoProcess; const hasCommandLinePasswordStore = preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; const linuxElectronOptions = environment.platform === "linux" && !hasCommandLinePasswordStore - ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() + ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess( + yield* HostProcess.HomeDirectory, + ) : preReadyElectronOptions.linux; if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { if ( @@ -329,6 +334,7 @@ const startup = Effect.gen(function* () { }); } yield* appIdentity.configure; + yield* previewPasskeys.configure; yield* applicationMenu.configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index 8ab165afce0a..dfe415644760 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -11,7 +11,7 @@ import { type DesktopAppActivationResponse, } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; -import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { afterEach, describe, expect } from "vite-plus/test"; @@ -83,8 +83,8 @@ function exchange(address: string, payload: DesktopAppActivationRequest) { describe("desktop app control server", () => { it.effect("roundtrips a request and removes its socket on shutdown", () => Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - const userId = yield* HostProcessUserId; + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; yield* Effect.promise(async () => { const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-")); const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); @@ -123,8 +123,8 @@ describe("desktop app control server", () => { it.effect("cancels a queued request when the client disconnects", () => Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - const userId = yield* HostProcessUserId; + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; yield* Effect.promise(async () => { const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-cancel-test-")); const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); @@ -162,8 +162,8 @@ describe("desktop app control server", () => { // Two desktop apps can share one state dir, such as nightly and a preview build. it.effect("keeps a newer app's socket when an older app on the same state dir quits", () => Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - const userId = yield* HostProcessUserId; + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; if (platform === "win32") return; yield* Effect.promise(async () => { const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-takeover-test-")); @@ -183,8 +183,8 @@ describe("desktop app control server", () => { it.effect("binds its address again after the socket file is removed", () => Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - const userId = yield* HostProcessUserId; + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; if (platform === "win32") return; yield* Effect.promise(async () => { const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-reclaim-test-")); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 740799955da2..19f24460106e 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -12,7 +12,7 @@ import { type DesktopAppActivationResponse, } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; -import { HostProcessUserId } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -315,7 +315,7 @@ export const make = Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronWindow = yield* ElectronWindow.ElectronWindow; const path = yield* Path.Path; - const userId = yield* HostProcessUserId; + const userId = yield* HostProcess.UserId; const runPromise = Effect.runPromiseWith(yield* Effect.context()); const address = resolveDesktopAppControlAddress({ stateDir: path.resolve(desktopEnvironment.stateDir), diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 4b4b90c29728..b4aef409337e 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -3,7 +3,7 @@ import * as NodeHttp from "node:http"; import * as NodePath from "@effect/platform-node/NodePath"; import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { HostProcessArguments } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -35,9 +35,16 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopWebLinks from "./DesktopWebLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; +/** Clerk forwards web links; tests that are not about links ignore them. */ +const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({ + receive: () => Effect.void, + setRendererReady: () => Effect.void, +}); + const layerDesktopClerk = ( isDevelopment = true, events: string[] = [], @@ -220,11 +227,12 @@ describe("DesktopClerk", () => { assert.isTrue(Exit.isSuccess(exit)); assert.equal(quit.mock.calls.length, 0); - assert.deepEqual(registeredEvents, ["open-url", "second-instance"]); + assert.deepEqual(registeredEvents, ["open-url", "open-file", "second-instance"]); }).pipe( Effect.provide(layerDesktopClerk()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); }); @@ -253,6 +261,7 @@ describe("DesktopClerk", () => { Effect.provide(layerDesktopClerk()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); }); }); @@ -299,6 +308,7 @@ it.effect( Effect.provide(layerDesktopClerk()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); }, ); @@ -375,12 +385,60 @@ it.effect.each(["startup", "open-url"] as const)( assert.strictEqual(delivery?.returnUrl, request.returnUrl); }).pipe( Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, shell)), - Effect.provideService(HostProcessArguments, entry === "startup" ? ["t3", link] : ["t3"]), + Effect.provideService(HostProcess.Arguments, entry === "startup" ? ["t3", link] : ["t3"]), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService( ElectronWindow.ElectronWindow, {} as ElectronWindow.ElectronWindow["Service"], ), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); }).pipe(Effect.scoped), ); + +it.effect("hands a web link to the renderer and leaves other links alone", () => + Effect.gen(function* () { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const electronApp = { + whenReady: Effect.void, + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => { + listeners.set(name, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const received: Array = []; + yield* Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + const open = (url: string) => { + const event = { preventDefault: vi.fn() }; + listeners.get("open-url")!(event, url); + return event.preventDefault.mock.calls.length; + }; + // macOS hands the default browser every web link. + assert.strictEqual(open("https://example.com/page"), 1); + assert.strictEqual(open("http://localhost:3000/"), 1); + // Anything else is not a web page; Electron keeps its own handling. + assert.strictEqual(open("mailto:hello@example.com"), 0); + yield* Effect.yieldNow; + assert.deepStrictEqual(received, ["https://example.com/page", "http://localhost:3000/"]); + }).pipe( + Effect.provide(layerDesktopClerk(true, [], "darwin")), + Effect.provideService(HostProcess.Arguments, ["t3"]), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService( + ElectronWindow.ElectronWindow, + {} as ElectronWindow.ElectronWindow["Service"], + ), + Effect.provideService( + DesktopWebLinks.DesktopWebLinks, + DesktopWebLinks.DesktopWebLinks.of({ + receive: (url) => Effect.sync(() => void received.push(url)), + setRendererReady: () => Effect.void, + }), + ), + ); + }).pipe(Effect.scoped), +); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 1bc390ac3c62..08d3def1a689 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -1,4 +1,5 @@ import { createClerkBridge } from "@clerk/electron"; +import * as NodeURL from "node:url"; import { storage } from "@clerk/electron/storage"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -11,13 +12,14 @@ import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/code import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; -import { HostProcessArguments } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopWebLinks from "./DesktopWebLinks.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service< readonly configure: Effect.Effect< void, never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + | ElectronApp.ElectronApp + | ElectronWindow.ElectronWindow + | DesktopWebLinks.DesktopWebLinks + | Scope.Scope >; } >()("@t3tools/desktop/app/DesktopClerk") {} @@ -125,6 +130,7 @@ export const make = Effect.gen(function* () { configure: Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronWindow = yield* ElectronWindow.ElectronWindow; + const webLinks = yield* DesktopWebLinks.DesktopWebLinks; const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); @@ -182,10 +188,23 @@ export const make = Effect.gen(function* () { ); return true; }; - const args = yield* HostProcessArguments; + const args = yield* HostProcess.Arguments; args.some((value) => startProviderAuthHandoff(value)); + // As the default browser, macOS hands T3 Code every web link through the same event. + const openWebLink = (url: string) => { + if (!DesktopWebLinks.isWebLink(url)) return false; + void runPromise(webLinks.receive(url)); + return true; + }; yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { - if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault(); + if (startProviderAuthHandoff(url) || resumeProviderAuth(url) || openWebLink(url)) + event.preventDefault(); + }); + // A browser opens HTML files too, which macOS hands over by path. + yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { + if (!DesktopWebLinks.isWebPageFile(path)) return; + event.preventDefault(); + void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href)); }); yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value))) diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts index ab4c4f86cdf1..542f6b987594 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -32,7 +32,7 @@ it.layer(NodeServices.layer)("DesktopPreReadyFileSystem", (it) => { }), ); - it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32" || process.getuid?.() === 0)( + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32" || process.getuid?.() === 0)( "fails instead of treating an unreadable profile as missing", () => Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 4fa5d417aa64..ecdc94d8254e 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -75,7 +75,7 @@ describe("DesktopPreReadyPlatform", () => { }).pipe( Effect.provide( DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), ), ), ); @@ -111,7 +111,7 @@ describe("DesktopPreReadyPlatform", () => { })); yield* Layer.build( DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), ), ); const identity = yield* Effect.promise(() => portalIdentity); @@ -135,7 +135,7 @@ describe("DesktopPreReadyPlatform", () => { }); return DesktopPreReadyPlatform.make.pipe( - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), Effect.asVoid, ); }); @@ -151,7 +151,7 @@ describe("DesktopPreReadyPlatform", () => { assert.include(contents, "MimeType=x-scheme-handler/t3code;"); assert.include(contents, "Icon="); assert.equal(setDesktopNameMock.mock.calls.length, 1); - }).pipe(Effect.provideService(HostProcessPlatform, "linux")); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")); }); it.effect( @@ -168,7 +168,7 @@ describe("DesktopPreReadyPlatform", () => { }); const layerPreReady = DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), + Layer.provide(Layer.succeed(HostProcess.Platform, "darwin")), ); const layerClerkShaped = Layer.effect( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 9b73a5d825bf..378eb58f160c 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -1,13 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available. import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; @@ -31,14 +30,15 @@ function readCommandLineSwitchValue( return value.length > 0 ? value : null; } -export const resolveEarlyLinuxElectronOptionsFromProcess = - (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => - DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ - env: process.env, - homeDirectory: NodeOS.homedir(), - joinPath: NodePath.posix.join, - readFileString: (path) => NodeFS.readFileSync(path, "utf8"), - }); +export const resolveEarlyLinuxElectronOptionsFromProcess = ( + homeDirectory: string, +): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => + DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + env: process.env, + homeDirectory, + joinPath: NodePath.posix.join, + readFileString: (path) => NodeFS.readFileSync(path, "utf8"), + }); export class DesktopPreReadyElectronOptions extends Context.Service< DesktopPreReadyElectronOptions, @@ -50,13 +50,15 @@ export class DesktopPreReadyElectronOptions extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; + const homeDirectory = yield* HostProcess.HomeDirectory; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { const linuxPasswordStoreCommandLine = platform === "linux" ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") : null; - const linux = platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess() : null; + const linux = + platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess(homeDirectory) : null; if (linux !== null) { // The portal also requires a valid desktop entry. An AppImage update may @@ -64,7 +66,7 @@ export const make = Effect.gen(function* () { try { const applicationsDir = NodePath.posix.join( process.env.XDG_DATA_HOME?.trim() || - NodePath.posix.join(NodeOS.homedir(), ".local", "share"), + NodePath.posix.join(homeDirectory, ".local", "share"), "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); diff --git a/apps/desktop/src/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts new file mode 100644 index 000000000000..302fe5f1b4ca --- /dev/null +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -0,0 +1,93 @@ +// @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron webContents. +import { assert, describe, it } from "@effect/vitest"; +import * as NodeEvents from "node:events"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopWebLinks from "./DesktopWebLinks.ts"; + +const makeWindow = () => { + const sent: Array = []; + const revealed: Array = []; + const webContents = Object.assign(new NodeEvents.EventEmitter(), { + isDestroyed: () => false, + send: (channel: string, url: string) => { + if (channel === WEB_LINK_OPEN_CHANNEL) sent.push(url); + }, + }); + const window = { webContents } as unknown as Electron.BrowserWindow; + /** The web app starts loading a new page, as a reload does. */ + const reload = () => + webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); + const layer = DesktopWebLinks.layer.pipe( + Layer.provide( + Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)), + } as unknown as ElectronWindow.ElectronWindow["Service"]), + ), + ), + ); + return { sent, revealed, layer, reload }; +}; + +describe("DesktopWebLinks", () => { + it("treats only http and https as web links", () => { + assert.isTrue(DesktopWebLinks.isWebLink("https://example.com/")); + assert.isTrue(DesktopWebLinks.isWebLink("http://localhost:3000/a?b=1")); + assert.isFalse(DesktopWebLinks.isWebLink("t3code://app/welcome")); + assert.isFalse(DesktopWebLinks.isWebLink("mailto:hello@example.com")); + assert.isFalse(DesktopWebLinks.isWebLink("not a url")); + }); + + it("opens HTML files the way a browser does, and nothing else by path", () => { + assert.isTrue(DesktopWebLinks.isWebPageFile("/Users/me/report.html")); + assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.HTM")); + assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.xhtml")); + assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/notes.md")); + assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/html")); + }); + + it.effect("holds links until the renderer listens, then delivers them in order", () => { + const { sent, revealed, layer } = makeWindow(); + return Effect.gen(function* () { + const webLinks = yield* DesktopWebLinks.DesktopWebLinks; + // The link that launched the app arrives before the web app has loaded. + yield* webLinks.receive("https://example.com/first"); + yield* webLinks.receive("https://example.com/second"); + assert.deepStrictEqual(sent, []); + + yield* webLinks.setRendererReady(true); + assert.deepStrictEqual(sent, ["https://example.com/first", "https://example.com/second"]); + assert.strictEqual(revealed.length, 1); + + yield* webLinks.receive("https://example.com/third"); + assert.deepStrictEqual(sent.at(-1), "https://example.com/third"); + + // A reloading web app stops listening; links wait for it again. + yield* webLinks.setRendererReady(false); + yield* webLinks.receive("https://example.com/fourth"); + assert.strictEqual(sent.length, 3); + }).pipe(Effect.provide(layer)); + }); + + it.effect("holds links that arrive while the web app reloads until it listens again", () => { + const { sent, layer, reload } = makeWindow(); + return Effect.gen(function* () { + const webLinks = yield* DesktopWebLinks.DesktopWebLinks; + yield* webLinks.setRendererReady(true); + // The old page never says it stopped; the reload itself ends its listening. + reload(); + yield* webLinks.receive("https://example.com/during-reload"); + assert.deepStrictEqual(sent, []); + + yield* webLinks.setRendererReady(true); + assert.deepStrictEqual(sent, ["https://example.com/during-reload"]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts new file mode 100644 index 000000000000..44d90534d009 --- /dev/null +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -0,0 +1,97 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; + +/** An http(s) link the operating system handed to T3 Code as the default browser. */ +export const isWebLink = (value: string) => { + if (!URL.canParse(value)) return false; + const { protocol } = new URL(value); + return protocol === "http:" || protocol === "https:"; +}; + +/** An HTML file the operating system asked T3 Code to open, as browsers are asked to. */ +export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path); + +/** + * Web links and HTML files macOS opens with T3 Code once it is the default + * browser. Files arrive as file:// URLs. They can + * arrive before the app is ready (the link that launched it) or while the web + * app reloads, so they wait until the renderer says it is listening. + */ +export class DesktopWebLinks extends Context.Service< + DesktopWebLinks, + { + /** Queues a link and delivers it as soon as the renderer is listening. */ + readonly receive: (url: string) => Effect.Effect; + /** The renderer started or stopped listening; listening flushes queued links. */ + readonly setRendererReady: (ready: boolean) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopWebLinks") {} + +const make = Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const pending: Array = []; + /** The page that said it is listening; a reload or crash replaces it, and it must say so again. */ + let listening: Electron.WebContents | null = null; + let detachListening: (() => void) | null = null; + + const stopListening = () => { + detachListening?.(); + detachListening = null; + listening = null; + }; + + const flush = Effect.gen(function* () { + if (listening === null || pending.length === 0) return; + const window = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(window) || window.value.webContents !== listening) return; + if (listening.isDestroyed()) { + stopListening(); + return; + } + for (const url of pending.splice(0)) listening.send(WEB_LINK_OPEN_CHANNEL, url); + yield* electronWindow.reveal(window.value); + }); + + const startListening = Effect.gen(function* () { + const window = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(window) || window.value.webContents.isDestroyed()) return; + const webContents = window.value.webContents; + if (listening === webContents) return; + stopListening(); + listening = webContents; + // Links that arrive while a new page loads wait for it to listen again. + const onNavigation = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) stopListening(); + }; + webContents.on("did-start-navigation", onNavigation); + webContents.on("render-process-gone", stopListening); + webContents.once("destroyed", stopListening); + detachListening = () => { + webContents.removeListener("did-start-navigation", onNavigation); + webContents.removeListener("render-process-gone", stopListening); + webContents.removeListener("destroyed", stopListening); + }; + }); + + return DesktopWebLinks.of({ + receive: (url) => + Effect.suspend(() => { + pending.push(url); + return flush; + }).pipe(Effect.withSpan("DesktopWebLinks.receive")), + setRendererReady: (ready) => + (ready ? startListening : Effect.sync(stopListening)).pipe( + Effect.andThen(flush), + Effect.withSpan("DesktopWebLinks.setRendererReady"), + ), + }); +}); + +export const layer = Layer.effect(DesktopWebLinks, make); diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts index ab6990ef1767..679d19a53d0a 100644 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -16,7 +16,7 @@ vi.mock("node:os", () => ({ import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; const layerTest = DesktopNetworkInterfaces.layer.pipe( - Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), ); describe("DesktopNetworkInterfaces", () => { diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts index de200f1b67b6..f2c32e1d0731 100644 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -1,6 +1,6 @@ import * as NodeOS from "node:os"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -41,7 +41,7 @@ export class DesktopNetworkInterfaces extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; return DesktopNetworkInterfaces.of({ read: Effect.try({ try: () => NodeOS.networkInterfaces(), diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 01b4fe080304..320988051e5f 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -28,7 +28,7 @@ vi.mock("electron", () => ({ import * as ElectronMenu from "./ElectronMenu.ts"; const layerTest = ElectronMenu.layer.pipe( - Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), ); const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 401e0c27ccd8..352a5bc97a21 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,5 +1,5 @@ import type { ContextMenuItem } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -114,7 +114,7 @@ const normalizePosition = ( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; let destructiveMenuIconCache: Option.Option | undefined; const getDestructiveMenuIcon = (): Option.Option => { diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 57ae5d6dcb69..cd01613d92fd 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const electronSafeStorageErrorFields = { cause: Schema.Defect(), @@ -67,7 +67,7 @@ export class ElectronSafeStorage extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; return ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 50dd15ba6df1..ef94312ed6f0 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -58,7 +58,7 @@ vi.mock("electron", () => ({ import * as ElectronWindow from "./ElectronWindow.ts"; const layerTestFor = (platform: NodeJS.Platform) => - ElectronWindow.layer.pipe(Layer.provide(Layer.succeed(HostProcessPlatform, platform))); + ElectronWindow.layer.pipe(Layer.provide(Layer.succeed(HostProcess.Platform, platform))); const layerTest = layerTestFor("linux"); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 1c8d82eb2cf9..a5e74494c12f 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -2,7 +2,7 @@ import * as NodePath from "node:path"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import type * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -129,7 +129,7 @@ export class ElectronWindow extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; // The focus worker loads a native accessibility module. Start it on the first // capture reveal so users who never capture pay nothing at launch. let windowsForegroundFocus: ReturnType | undefined; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index aa8ca1e917d8..98857991a6ad 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -77,6 +77,7 @@ import { pickBackgroundFolder, readBackgroundFolderImage, } from "./methods/backgroundFolder.ts"; +import * as WebLinksIpc from "./methods/webLinks.ts"; import { completeLegacyLocalStorage, takeLegacyLocalStorage, @@ -96,6 +97,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setDockMood); yield* ipc.handle(AppActivationIpc.setReady); yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handle(WebLinksIpc.setReady); yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 6ce5a43d3f13..ad0768c18a7b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -14,6 +14,8 @@ export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; +export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; +export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const SET_DICTATION_KEY_ENABLED_CHANNEL = "desktop:set-dictation-key-enabled"; export const DICTATION_KEY_CHANNEL = "desktop:dictation-key"; diff --git a/apps/desktop/src/ipc/methods/dockMood.ts b/apps/desktop/src/ipc/methods/dockMood.ts index 2ebd6076be94..7a056988d84e 100644 --- a/apps/desktop/src/ipc/methods/dockMood.ts +++ b/apps/desktop/src/ipc/methods/dockMood.ts @@ -2,7 +2,7 @@ import { DesktopMascotMoodSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopAssets from "../../app/DesktopAssets.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; @@ -14,7 +14,7 @@ export const setDockMood = DesktopIpc.makeIpcMethod({ payload: DesktopMascotMoodSchema, result: Schema.Void, handler: Effect.fn("desktop.ipc.setDockMood")(function* (mood) { - if ((yield* HostProcessPlatform) !== "darwin") return; + if ((yield* HostProcess.Platform) !== "darwin") return; const assets = yield* DesktopAssets.DesktopAssets; const iconPath = yield* assets .resolveResourcePath(`mascot/${mood}.png`) diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts index a47732550f64..40d1df6a3366 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.test.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import { beforeEach, expect, vi } from "vite-plus/test"; import { it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const native = vi.hoisted(() => ({ setBadgeCount: vi.fn(), @@ -122,7 +122,7 @@ it.effect("validates IPC and clears on native focus, quit, and disposal", () => expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); }), ).pipe( - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), Effect.provide([ ElectronApp.layer, DesktopIpc.layer({ diff --git a/apps/desktop/src/ipc/methods/notificationBadge.ts b/apps/desktop/src/ipc/methods/notificationBadge.ts index 40e4549138f2..952745b4b7c6 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.ts @@ -1,7 +1,7 @@ import * as Electron from "electron"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -45,7 +45,7 @@ export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificati function* () { const ipc = yield* DesktopIpc.DesktopIpc; const app = yield* ElectronApp.ElectronApp; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const clear = () => { applyNotificationBadge(platform, { count: 0, image: null }); for (const window of Electron.BrowserWindow.getAllWindows()) { diff --git a/apps/desktop/src/ipc/methods/webLinks.ts b/apps/desktop/src/ipc/methods/webLinks.ts new file mode 100644 index 000000000000..bda6affc0741 --- /dev/null +++ b/apps/desktop/src/ipc/methods/webLinks.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopWebLinks from "../../app/DesktopWebLinks.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const setReady = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.WEB_LINK_READY_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.webLinks.setReady")(function* (ready) { + const webLinks = yield* DesktopWebLinks.DesktopWebLinks; + yield* webLinks.setRendererReady(ready); + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 72f631d162d1..a6913528653c 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; @@ -314,7 +314,7 @@ describe("pickProjectFavicon", () => { ); }); -it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "finds remote editors installed without PATH launchers", () => Effect.gen(function* () { @@ -333,11 +333,11 @@ it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( yield* fs.chmod(executable, 0o755); } const editors = yield* probeRemoteEditors.handler(undefined).pipe( - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { HOME: home, PATH: path.join(home, "empty"), }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Platform, "darwin"), ); assert.include(editors, "cursor"); assert.include(editors, "vscode"); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 64e0566d3e70..47e6c02ffe52 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -15,8 +15,7 @@ import { } from "@t3tools/contracts"; import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; import { resolveEditorCommand } from "@t3tools/shared/editor"; -import * as HostProcess from "@t3tools/shared/hostProcess"; -import * as NodeOS from "node:os"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; @@ -345,7 +344,7 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ result: Schema.Array(EditorId), handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { const available: Array = []; - const env = yield* HostProcess.HostProcessEnvironment; + const env = yield* HostProcess.Environment; for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { const editor = EDITORS.find((editor) => editor.id === editorId); if (editor && Option.isSome(yield* resolveEditorCommand(editor, env))) { @@ -398,7 +397,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ // The VS Code extensions directory is the same dotfolder on Windows, // macOS, and Linux; when it is missing the picker opens wherever the // platform would by default. - const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions"); + const extensionsDir = path.join(yield* HostProcess.HomeDirectory, ".vscode", "extensions"); const defaultPath = yield* fileSystem .exists(extensionsDir) .pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e2a9a92b24f0..69bd6e23a6ec 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,7 +18,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -35,6 +35,7 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; +import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -72,6 +73,7 @@ import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret. import * as BrowserSession from "./preview/BrowserSession.ts"; import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts"; import * as PreviewManager from "./preview/Manager.ts"; +import * as PreviewPasskeys from "./preview/Passkeys.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; @@ -91,8 +93,8 @@ const layerDesktopEnvironment = Layer.unwrap( const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( Effect.flatMap((app) => app.metadata), ); - const platform = yield* HostProcessPlatform; - const processArch = yield* HostProcessArchitecture; + const platform = yield* HostProcess.Platform; + const processArch = yield* HostProcess.Architecture; return DesktopEnvironment.layer({ dirname: __dirname, homeDirectory: NodeOS.homedir(), @@ -172,6 +174,7 @@ const layerDesktopPreview = PreviewManager.layer.pipe( // service alongside the manager; both sit on the same BrowserSession. Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), Layer.provideMerge(BrowserSession.layer), + Layer.provideMerge(PreviewPasskeys.layer), Layer.provideMerge(layerDesktopFoundation), ); @@ -216,6 +219,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( const layerDesktopApplication = Layer.mergeAll( DesktopLifecycle.layer, layerDesktopAppActivation, + DesktopWebLinks.layer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopCliCommand.layer, diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index c02b338c556b..3f395c81ffc4 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -52,7 +52,7 @@ body { margin: 0; background: transparent; color: var(--text); font: 15px/22px - #panel { position: relative; margin: 2px; padding: 20px; height: 136px; border: 1px solid var(--line); border-radius: 24px; background: var(--base); } header { font-weight: 600; white-space: nowrap; } button { font: inherit; color: inherit; } -button:focus-visible { outline: 2px solid #007aff; outline-offset: 3px; } +button:focus-visible { outline: 2px solid #007aff; outline-offset: -2px; } #close { position: absolute; right: 8px; top: 6px; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%; background: var(--base); font-size: 18px; cursor: pointer; opacity: 0; } #panel:hover #close, #close:focus-visible { opacity: 1; } #app { display: flex; align-items: center; gap: 12px; width: 100%; height: 52px; margin-top: 20px; padding: 8px 12px; border: 0; border-radius: 10px; background: var(--row); cursor: grab; text-align: left; font-size: 16px; font-weight: 600; } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 007f83587315..7752f15fc430 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -317,6 +317,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + webLinks: { + setReady: (ready) => ipcRenderer.invoke(IpcChannels.WEB_LINK_READY_CHANNEL, ready), + onOpen: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, url: unknown) => { + if (typeof url === "string") listener(url); + }; + ipcRenderer.on(IpcChannels.WEB_LINK_OPEN_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WEB_LINK_OPEN_CHANNEL, wrappedListener); + }; + }, + }, appActivation: { setReady: (ready) => ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index 84e6abb29ee6..7c4f23e37f50 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -1 +1,10 @@ +import { ipcRenderer } from "electron"; + +import { PASSKEY_BRIDGE_ARGUMENT } from "./preview/GuestProtocol.ts"; +import { installPasskeyBridge } from "./preview/PasskeyBridge.ts"; import "./preview/PickPreload.ts"; + +// Electron hands webPreferences.additionalArguments to sandboxed preloads in process.argv. +if (process.argv.includes(PASSKEY_BRIDGE_ARGUMENT)) { + installPasskeyBridge((channel, publicKey) => ipcRenderer.invoke(channel, publicKey)); +} diff --git a/apps/desktop/src/preview/Annotation.css b/apps/desktop/src/preview/Annotation.css index 89676a22d585..0897aad4f86d 100644 --- a/apps/desktop/src/preview/Annotation.css +++ b/apps/desktop/src/preview/Annotation.css @@ -64,5 +64,5 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); - outline-offset: 1px; + outline-offset: -2px; } diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index aba581ab5338..ce8dbfb43e6e 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.static {\n position: static;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 0f1e2bd386ce..edd4e981dee5 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -1,10 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; @@ -52,10 +48,10 @@ const layerRejectedBeforeSession = Layer.succeed( const withImporter = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); - const layerEnvironment = Layer.succeed(HostProcessEnvironment, { HOME: home }); + const layerEnvironment = Layer.succeed(HostProcess.Environment, { HOME: home }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), ); const root = helium.userDataDirectory(context); if (root === undefined) throw new Error("Helium has no macOS user-data directory"); @@ -69,8 +65,8 @@ const withImporter = Effect.fnUntraced(function* () { BrowserImport.layer.pipe( Layer.provide(layerRejectedBeforeSession), Layer.provide(layerEnvironment), - Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), - Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")), + Layer.provide(Layer.succeed(HostProcess.Platform, "darwin")), + Layer.provide(Layer.succeed(HostProcess.ExecutablePath, "/Applications/T3 Code.app")), Layer.provide(NodeServices.layer), ), ), diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 8893f2322410..69fccc5b761d 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -21,7 +21,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/process"; -import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as BrowserSession from "../BrowserSession.ts"; import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; @@ -169,8 +169,8 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; - const platform = yield* HostProcessPlatform; - const executablePath = yield* HostProcessExecutablePath; + const platform = yield* HostProcess.Platform; + const executablePath = yield* HostProcess.ExecutablePath; // Captured here so the service's methods stay free of a requirements // channel: the layer is built where NodeServices is already in scope. const platformServices = yield* Effect.context< diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index 2c2fd425df56..d524bf0938fc 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -315,7 +315,7 @@ export interface ChromiumCookieSource { readonly keychainAccount: string | undefined; readonly linuxSecretApplication: string | undefined; readonly windowsLocalStatePath?: string; - /** Supplied by the caller from `HostProcessPlatform` rather than read here. */ + /** Supplied by the caller from `HostProcess.Platform` rather than read here. */ readonly platform: NodeJS.Platform; } diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts index 0466d6b4749c..cdeb67e0e8cc 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -232,7 +232,7 @@ describe("Linux Chromium secrets", () => { capture: (value) => (captured = value), }), ), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { PATH: "/synthetic/bin", SESSION_MARKER: "kept", LC_ALL: "localized", @@ -322,7 +322,7 @@ describe("Windows Chromium secrets", () => { Effect.provide( layerHelper({ stdout: key.toString("base64"), capture: (value) => (captured = value) }), ), - Effect.provideService(HostProcessEnvironment, { SystemRoot: "C:\\Windows" }), + Effect.provideService(HostProcess.Environment, { SystemRoot: "C:\\Windows" }), ); }); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index 35e1e9c4b55e..ef7c8685b707 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -19,7 +19,7 @@ */ import * as NodeCrypto from "node:crypto"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; @@ -138,7 +138,7 @@ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(functio return yield* Effect.scoped( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = yield* HostProcessEnvironment; + const environment = yield* HostProcess.Environment; const helper = yield* LinuxBrowserSecretPath; if (helper === undefined) { return yield* new ChromiumKeyError({ reason: "keychainUnavailable" }); @@ -219,7 +219,7 @@ export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrap export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* ( wrapped: Buffer, ) { - const environment = yield* HostProcessEnvironment; + const environment = yield* HostProcess.Environment; return yield* Effect.scoped( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts index cd41673a3f14..3b33f872d124 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; @@ -30,8 +30,8 @@ it.effect("detects Safari access becoming available without reading or importing ), }); const check = yield* safariPermissionCheck.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), Effect.provideService(FileSystem.FileSystem, guardedFs), ); assert.isFalse(yield* Effect.promise(check)); @@ -47,8 +47,8 @@ it.effect("recognizes access when cookies exist only in a named Safari profile", const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-safari-named-access-" }); const check = yield* safariPermissionCheck.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), ); assert.isFalse(yield* Effect.promise(check)); const directory = `${home}/Library/Containers/com.apple.Safari/Data/Library/WebKit/WebsiteDataStore/12345678-1234-1234-1234-123456789abc/Cookies`; diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 4a04e0498cb6..51bab02ce939 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -3,11 +3,7 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { - HostProcessEnvironment, - HostProcessHostname, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -80,8 +76,8 @@ const withSourceHome = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), ); yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true }); return context; @@ -133,8 +129,8 @@ describe("Helium on Linux", () => { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-helium-linux-" }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), ); const root = `${home}/.config/net.imput.helium`; yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); @@ -162,11 +158,11 @@ describe("Helium on Windows", () => { run( Effect.gen(function* () { const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { USERPROFILE: "C:\\Users\\browser-user", LOCALAPPDATA: "C:\\Users\\browser-user\\AppData\\Local", }), - Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcess.Platform, "win32"), ); assert.include(helium.platforms, "win32"); @@ -198,11 +194,11 @@ describe("isSourceRunning", () => { prefix: "t3code-helium-windows-lock-", }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { HOME: home, LOCALAPPDATA: home, }), - Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcess.Platform, "win32"), ); const profile = context.path.join(helium.userDataDirectory(context)!, "Default"); const database = context.path.join(profile, "Network", "Cookies"); @@ -259,12 +255,12 @@ describe("isSourceRunning", () => { assert.isTrue( yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcessHostname, "another-host"), + Effect.provideService(HostProcess.Hostname, "another-host"), ), ); assert.isFalse( yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcessHostname, "lock-owner"), + Effect.provideService(HostProcess.Hostname, "lock-owner"), ), ); }), @@ -656,8 +652,8 @@ describe("cookieDatabaseCandidatePaths", () => { Effect.gen(function* () { const path = yield* Path.Path; const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: "/tmp/test" }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: "/tmp/test" }), + Effect.provideService(HostProcess.Platform, "darwin"), ); const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default"); assert.deepEqual(candidates, [ @@ -684,8 +680,8 @@ describe("Firefox Snap profiles", () => { prefix: "t3code-firefox-snap-", }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), ); const root = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"); const directory = context.path.join(root, "abcd.default"); @@ -724,8 +720,8 @@ describe("Firefox Snap profiles", () => { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), ); const native = context.path.join(home, ".mozilla", "firefox"); const snap = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"); @@ -775,11 +771,11 @@ describe("listSourceProfiles Firefox fallback", () => { }); const appData = path.join(home, "AppData", "Roaming"); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { HOME: home, APPDATA: appData, }), - Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcess.Platform, platform), ); const root = firefox.userDataDirectory(context)!; const scanRoot = platform === "linux" ? root : path.join(root, "Profiles"); @@ -812,8 +808,8 @@ describe("listSourceProfiles Firefox fallback", () => { prefix: "t3code-firefox-stale-ini-", }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), ); const root = firefox.userDataDirectory(context)!; // `profiles.ini` names a profile that was never launched (no cookie @@ -851,8 +847,8 @@ describe("listSourceProfiles Firefox fallback", () => { prefix: "t3code-firefox-counts-", }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), ); const root = firefox.userDataDirectory(context)!; const declaredDirectory = path.join(root, "Profiles", "declared.default"); @@ -902,8 +898,8 @@ describe("isSourceRunning for Firefox", () => { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), ); const root = firefox.userDataDirectory(context)!; const profile = `${root}/Profiles/abcd.default-release`; @@ -952,7 +948,7 @@ describe("isSourceRunning for Firefox", () => { ); // Holds the lock with python3's fcntl, which does not exist on Windows. - it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", () => run( @@ -961,8 +957,8 @@ describe("isSourceRunning for Firefox", () => { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), ); const root = firefox.userDataDirectory(context)!; const profile = `${root}/Profiles/abcd.default-release`; @@ -1033,11 +1029,11 @@ describe("isSourceRunning for Firefox", () => { const context = yield* sourcePathContext.pipe( // Firefox's win32 root hangs off %APPDATA%; without it the root is // undefined and the fixture would escape the sandbox into the repo. - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { HOME: home, APPDATA: `${home}/AppData/Roaming`, }), - Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcess.Platform, "win32"), ); const root = firefox.userDataDirectory(context)!; const profile = `${root}/Profiles/gx7x7fqx.default-release`; diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index fa8f79e1ccba..86baffbf1b2f 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -17,12 +17,7 @@ */ import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -import { - HostProcessEnvironment, - HostProcessAddresses, - HostProcessHostname, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -335,8 +330,8 @@ export function parseFirefoxProfiles( */ export const sourcePathContext = Effect.gen(function* () { const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; return { path, platform, @@ -782,7 +777,7 @@ export const posixLockIsHeld = Effect.fnUntraced(function* ( interpreters: ReadonlyArray = FCNTL_PROBE_INTERPRETERS, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = yield* HostProcessEnvironment; + const environment = yield* HostProcess.Environment; for (const interpreter of interpreters) { const verdict = yield* Effect.scoped( Effect.gen(function* () { @@ -872,7 +867,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") if (context.platform === "win32") { return yield* windowsChromiumCookiesAreHeld(definition, context); } - const currentHost = yield* HostProcessHostname; + const currentHost = yield* HostProcess.Hostname; const lock = context.path.join(root, "SingletonLock"); return yield* fileSystem.readLink(lock).pipe( Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)), @@ -883,7 +878,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const profiles = yield* listSourceProfiles(definition, context); // Only the Linux `lock` symlink names an address, so Windows skips the lookup. const localAddresses: ReadonlySet = - context.platform === "win32" ? new Set() : yield* yield* HostProcessAddresses; + context.platform === "win32" ? new Set() : yield* yield* HostProcess.Addresses; const found = yield* Effect.forEach(profiles, (profile) => { const directory = context.path.isAbsolute(profile.directory) ? profile.directory diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 5019076a100d..4a3eea297c12 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -11,3 +11,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; +export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; +export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; +/** Renderer argument that turns on the guest passkey bridge; see Passkeys.ts. */ +export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 84c6273aad62..6d585a88641c 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -5,7 +5,7 @@ import type { DesktopPreviewRecordingFrame, DesktopPreviewRecordingInputEvent, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -29,6 +29,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as PreviewManager from "./Manager.ts"; +import * as PreviewPasskeys from "./Passkeys.ts"; describe("fitPictureInPictureContentSize", () => { it("preserves the PiP content area across aspect-ratio changes", () => { @@ -301,11 +302,18 @@ const managerLayer = (platform: NodeJS.Platform = "darwin") => ), Layer.provideMerge(DesktopBrowserHost.layer), Layer.provideMerge(layerBrowserSession), + Layer.provideMerge( + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), + ), Layer.provideMerge(layerEnvironment), Layer.provideMerge(layerFileSystem), Layer.provideMerge(Path.layer), Layer.provideMerge(NodeCrypto.layer), - Layer.provideMerge(Layer.succeed(HostProcessPlatform, platform)), + Layer.provideMerge(Layer.succeed(HostProcess.Platform, platform)), ); const withManager = ( diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 123943f70f9a..aa36055452eb 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -25,7 +25,7 @@ import type { DesktopPreviewTabDefaults, PreviewForwardedShortcut, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { @@ -64,6 +64,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; +import * as PreviewPasskeys from "./Passkeys.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -543,11 +544,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const fileSystem = yield* FileSystem.FileSystem; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; - const hostPlatform = yield* HostProcessPlatform; + const hostPlatform = yield* HostProcess.Platform; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const parentScope = yield* Scope.Scope; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); @@ -1254,6 +1256,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const scope = yield* Scope.fork(parentScope, "sequential"); const attachmentId = Symbol(); + let detachPasskeys = () => {}; let documentId = 0; let nextRequestId = 0; let activeCapture: { @@ -1589,6 +1592,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + detachPasskeys(); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { @@ -1609,6 +1613,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { const action = previewWindowOpenAction(details); if (action === "popup") { @@ -3686,6 +3691,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; const downloadSessions = new WeakSet(); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -3750,6 +3756,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { ), ); placeServerDownloads(session); + passkeys.installSessionHandlers(session); return session; }, ), diff --git a/apps/desktop/src/preview/PasskeyAttestation.ts b/apps/desktop/src/preview/PasskeyAttestation.ts new file mode 100644 index 000000000000..17927a2957da --- /dev/null +++ b/apps/desktop/src/preview/PasskeyAttestation.ts @@ -0,0 +1,59 @@ +/** + * Reads the raw `authData` bytes out of a CBOR attestation object + * (`{ fmt, attStmt, authData }`). Returns undefined for anything malformed. + * Only the definite-length encodings authenticators emit are understood. + */ +export const authenticatorDataFromAttestation = (bytes: Uint8Array) => { + let offset = 0; + const readHead = () => { + const initial = bytes[offset++]; + if (initial === undefined) return undefined; + const info = initial & 31; + let length = info; + if (info >= 24) { + if (info > 27) return undefined; + const size = 1 << (info - 24); + if (offset + size > bytes.length) return undefined; + length = 0; + for (let index = 0; index < size; index++) length = length * 256 + (bytes[offset++] ?? 0); + } + return { major: initial >> 5, length }; + }; + const skipValue = (): boolean => { + const head = readHead(); + if (!head) return false; + switch (head.major) { + case 2: + case 3: + offset += head.length; + return offset <= bytes.length; + case 4: + for (let index = 0; index < head.length; index++) if (!skipValue()) return false; + return true; + case 5: + for (let index = 0; index < head.length * 2; index++) if (!skipValue()) return false; + return true; + case 6: + return skipValue(); + default: + return true; + } + }; + + const map = readHead(); + if (map?.major !== 5) return undefined; + for (let entry = 0; entry < map.length; entry++) { + const key = readHead(); + if (key?.major !== 3 || offset + key.length > bytes.length) return undefined; + const name = new TextDecoder().decode(bytes.subarray(offset, offset + key.length)); + offset += key.length; + if (name !== "authData") { + if (!skipValue()) return undefined; + continue; + } + const value = readHead(); + if (value?.major !== 2 || offset + value.length > bytes.length) return undefined; + return bytes.slice(offset, offset + value.length); + } + return undefined; +}; diff --git a/apps/desktop/src/preview/PasskeyBackend.ts b/apps/desktop/src/preview/PasskeyBackend.ts new file mode 100644 index 000000000000..ac174d2759a4 --- /dev/null +++ b/apps/desktop/src/preview/PasskeyBackend.ts @@ -0,0 +1,26 @@ +import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; + +/** What the shared bridge has already checked before a backend runs a ceremony. */ +export interface PasskeyCeremonyContext { + /** The committed origin of the frame that asked; never the page's own claim. */ + readonly origin: string; + /** The window the system sheet attaches to. */ + readonly nativeWindowHandle: Buffer; +} + +/** + * One platform's way to create and use passkeys for a preview page. The + * bridge in `Passkeys.ts` owns everything platform-neutral: which frame may + * ask, focus, one ceremony at a time, the deadline, and dropping results for + * a page that navigated away. A backend only talks to the platform. + */ +export interface PasskeyBackend { + readonly create: ( + publicKey: PublicKeyCredentialCreationOptions, + context: PasskeyCeremonyContext, + ) => Promise; + readonly get: ( + publicKey: PublicKeyCredentialRequestOptions, + context: PasskeyCeremonyContext, + ) => Promise; +} diff --git a/apps/desktop/src/preview/PasskeyBackendMac.ts b/apps/desktop/src/preview/PasskeyBackendMac.ts new file mode 100644 index 000000000000..a21d94df1cbe --- /dev/null +++ b/apps/desktop/src/preview/PasskeyBackendMac.ts @@ -0,0 +1,96 @@ +import { getPublicSuffix } from "tldts"; + +import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts"; +import { authenticatorDataFromAttestation } from "./PasskeyAttestation.ts"; +import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; + +const ES256 = -7; + +// WebAuthn rejects RP IDs that are public suffixes, private registries +// included, so one github.io site cannot mint passkeys for every other one. +const isPublicSuffix = (domain: string) => + domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain; + +// Loaded only once a ceremony runs: the module and its native addon ship on macOS alone. +const nativeOptions = async (context: PasskeyCeremonyContext) => ({ + webauthn: await import("electron-webauthn"), + options: { + currentOrigin: context.origin, + topFrameOrigin: context.origin, + nativeWindowHandle: context.nativeWindowHandle, + isPublicSuffix, + }, +}); + +const isBufferSource = (value: unknown): value is BufferSource => + value instanceof ArrayBuffer || ArrayBuffer.isView(value); + +const toBase64Url = (value: BufferSource) => + Buffer.from( + ArrayBuffer.isView(value) + ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + : new Uint8Array(value), + ).toString("base64url"); + +/** + * macOS passkeys through AuthenticationServices (`electron-webauthn`): the + * system sheet offers iCloud Keychain, password managers, phones, and security + * keys. Needs Apple's managed browser entitlement in the signed build. + */ +export const macPasskeyBackend: PasskeyBackend = { + create: async (publicKey, context): Promise => { + // The native layer only converts P-256 keys and never settles for any + // other algorithm, so ES256 is the only one it may negotiate. + const params: unknown = publicKey.pubKeyCredParams; + if (params !== undefined && !Array.isArray(params)) + return { success: false, error: "TypeError" }; + const allowsEs256 = + params === undefined || + params.length === 0 || + params.some( + (param: unknown) => + typeof param === "object" && param !== null && "alg" in param && param.alg === ES256, + ); + if (!allowsEs256) return { success: false, error: "NotSupportedError" }; + const { webauthn, options } = await nativeOptions(context); + const result = await webauthn.createCredential( + { ...publicKey, pubKeyCredParams: [{ type: "public-key", alg: ES256 }] }, + options, + ); + if (!result.success) return { success: false, error: result.error }; + // The native layer reports parsed authenticator data as JSON and claims + // every credential is a synced platform passkey; neither is reliable. + const authData = authenticatorDataFromAttestation( + Buffer.from(result.data.attestationObject, "base64url"), + ); + // Sites verify the new credential from its authenticator data; without it + // the passkey is unusable, so say so now rather than let sign-up fail later. + if (!authData) return { success: false, error: "NotAllowedError" }; + return { + success: true, + data: { + ...result.data, + authData: Buffer.from(authData).toString("base64url"), + transports: [], + }, + }; + }, + get: async (publicKey, context): Promise => { + const { webauthn, options } = await nativeOptions(context); + // WebAuthn defaults the RP ID to the caller's host; the native layer requires it. + const result = await webauthn.getCredential( + { ...publicKey, rpId: publicKey.rpId ?? new URL(context.origin).hostname }, + options, + ); + if (!result.success) return { success: false, error: result.error }; + // The native layer applies the site's allow list to platform passkeys only, + // so a security key can answer with a credential the site did not ask for. + const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : []) + .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id)) + .map((credential) => toBase64Url(credential.id)); + if (allowed.length > 0 && !allowed.includes(result.data.credentialId)) { + return { success: false, error: "NotAllowedError" }; + } + return result; + }, +}; diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts new file mode 100644 index 000000000000..3ca93190ae51 --- /dev/null +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -0,0 +1,246 @@ +import { assert, describe, it } from "@effect/vitest"; +import { afterEach, vi } from "vite-plus/test"; + +import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; +import { installPasskeyBridge } from "./PasskeyBridge.ts"; + +const base64Url = (bytes: ReadonlyArray) => Buffer.from(bytes).toString("base64url"); +const bytesOf = (buffer: ArrayBuffer | null) => (buffer ? [...new Uint8Array(buffer)] : null); + +const assertionData = { + credentialId: base64Url([1, 2, 3]), + clientDataJSON: base64Url([4]), + authenticatorData: base64Url([5]), + signature: base64Url([6, 7]), + userHandle: base64Url([8]), + extensions: { prf: { results: { first: base64Url([9]) } } }, +}; + +const attestationData = { + credentialId: base64Url([1, 2, 3]), + clientDataJSON: base64Url([4]), + attestationObject: base64Url([10]), + authData: base64Url([11]), + publicKey: base64Url([12]), + publicKeyAlgorithm: -7, + transports: [], + extensions: { credProps: { rk: true } }, +}; + +const creationOptions: PublicKeyCredentialCreationOptions = { + challenge: new Uint8Array([1]), + rp: { name: "Example" }, + user: { id: new Uint8Array([2]), name: "alice", displayName: "Alice" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], +}; + +const illegalInvocation = (): never => { + throw new TypeError("Illegal invocation"); +}; + +/** + * Installs the bridge over fresh stand-ins for the page's DOM classes. Like + * Chromium's, their getters only work on objects the browser created itself. + */ +const install = (respond: (channel: string) => unknown) => { + const nativeCreate = vi.fn(async () => "native-create"); + const nativeGet = vi.fn(async () => "native-get"); + class FakeCredentialsContainer { + create() { + return nativeCreate(); + } + get() { + return nativeGet(); + } + } + class FakePublicKeyCredential { + get id() { + return illegalInvocation(); + } + get response() { + return illegalInvocation(); + } + } + class FakeAttestationResponse { + get clientDataJSON() { + return illegalInvocation(); + } + } + class FakeAssertionResponse { + get signature() { + return illegalInvocation(); + } + } + vi.stubGlobal("CredentialsContainer", FakeCredentialsContainer); + vi.stubGlobal("PublicKeyCredential", FakePublicKeyCredential); + vi.stubGlobal("AuthenticatorAttestationResponse", FakeAttestationResponse); + vi.stubGlobal("AuthenticatorAssertionResponse", FakeAssertionResponse); + + const invoke = vi.fn(async (channel: string, _publicKey: unknown) => respond(channel)); + installPasskeyBridge(invoke); + return { + invoke, + nativeCreate, + nativeGet, + credentials: new FakeCredentialsContainer() as unknown as CredentialsContainer, + FakePublicKeyCredential, + FakeAttestationResponse, + FakeAssertionResponse, + }; +}; + +describe("installPasskeyBridge", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("serves passkey sign-in through the main process with a credential pages can verify", async () => { + const { invoke, credentials, FakePublicKeyCredential, FakeAssertionResponse } = install(() => ({ + success: true, + data: assertionData, + })); + const challenge = new Uint8Array([0, 1, 2, 3]).subarray(1); + + const credential = (await credentials.get({ + publicKey: { + challenge, + rpId: "example.com", + allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }], + // Unknown members the page adds must not break IPC cloning. + extensions: { onHint: () => {} } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential; + + assert.deepStrictEqual(invoke.mock.calls[0], [ + PASSKEY_GET_CHANNEL, + { + challenge: new Uint8Array([1, 2, 3]), + rpId: "example.com", + allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }], + extensions: {}, + }, + ]); + const response = credential.response as AuthenticatorAssertionResponse; + assert.instanceOf(credential, FakePublicKeyCredential); + assert.instanceOf(response, FakeAssertionResponse); + assert.strictEqual(credential.id, assertionData.credentialId); + assert.deepStrictEqual(bytesOf(credential.rawId), [1, 2, 3]); + assert.deepStrictEqual(bytesOf(response.signature), [6, 7]); + assert.deepStrictEqual(bytesOf(response.userHandle), [8]); + assert.deepStrictEqual( + bytesOf(credential.getClientExtensionResults().prf?.results?.first as ArrayBuffer), + [9], + ); + assert.deepStrictEqual(credential.toJSON(), { + id: assertionData.credentialId, + rawId: assertionData.credentialId, + type: "public-key", + response: { + clientDataJSON: assertionData.clientDataJSON, + authenticatorData: assertionData.authenticatorData, + signature: assertionData.signature, + userHandle: assertionData.userHandle, + }, + clientExtensionResults: { prf: { results: { first: base64Url([9]) } } }, + }); + }); + + it("returns registrations with their attestation and public key", async () => { + const { invoke, credentials, FakeAttestationResponse } = install(() => ({ + success: true, + data: attestationData, + })); + + const credential = (await credentials.create({ + publicKey: creationOptions, + })) as PublicKeyCredential; + + assert.strictEqual(invoke.mock.calls[0]?.[0], PASSKEY_CREATE_CHANNEL); + const response = credential.response as AuthenticatorAttestationResponse; + assert.instanceOf(response, FakeAttestationResponse); + assert.isNull(credential.authenticatorAttachment); + assert.deepStrictEqual(bytesOf(response.attestationObject), [10]); + assert.deepStrictEqual(bytesOf(response.getAuthenticatorData()), [11]); + assert.deepStrictEqual(bytesOf(response.getPublicKey()), [12]); + assert.strictEqual(response.getPublicKeyAlgorithm(), -7); + assert.deepStrictEqual(credential.getClientExtensionResults(), { credProps: { rk: true } }); + assert.deepStrictEqual(credential.toJSON(), { + id: attestationData.credentialId, + rawId: attestationData.credentialId, + type: "public-key", + response: { + clientDataJSON: attestationData.clientDataJSON, + attestationObject: attestationData.attestationObject, + authenticatorData: attestationData.authData, + transports: [], + publicKey: attestationData.publicKey, + publicKeyAlgorithm: -7, + }, + clientExtensionResults: { credProps: { rk: true } }, + }); + }); + + it("leaves autofill and non-passkey requests to Chromium", async () => { + const { invoke, credentials, nativeCreate, nativeGet } = install(() => ({ + success: true, + data: assertionData, + })); + + await credentials.get({ + mediation: "conditional", + publicKey: { challenge: new Uint8Array([1]) }, + }); + await credentials.get({ password: true } as CredentialRequestOptions); + // Conditional create upgrades a password sign-in silently; no modal sheet. + const upgrade = { mediation: "conditional", publicKey: creationOptions } as const; + await credentials.create(upgrade); + await credentials.create({ password: {} } as CredentialCreationOptions); + assert.strictEqual(invoke.mock.calls.length, 0); + assert.strictEqual(nativeGet.mock.calls.length, 2); + assert.strictEqual(nativeCreate.mock.calls.length, 2); + assert.isFalse(await PublicKeyCredential.isConditionalMediationAvailable()); + assert.isTrue(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()); + }); + + it("rejects with the WebAuthn error the ceremony ended with", async () => { + const { credentials } = install((channel) => ({ + success: false, + error: channel === PASSKEY_CREATE_CHANNEL ? "InvalidStateError" : "TypeError", + })); + const created = await credentials + .create({ publicKey: creationOptions }) + .catch((error: unknown) => error); + assert.instanceOf(created, DOMException); + assert.strictEqual((created as DOMException).name, "InvalidStateError"); + const fetched = await credentials + .get({ publicKey: { challenge: new Uint8Array([1]) } }) + .catch((error: unknown) => error); + assert.instanceOf(fetched, TypeError); + }); + + it("refuses when the main process has no handler for the page", async () => { + const { credentials } = install(() => { + throw new Error("Error invoking remote method 'preview:passkey-get': No handler registered"); + }); + + const error = await credentials + .get({ publicKey: { challenge: new Uint8Array([1]) } }) + .catch((caught: unknown) => caught); + assert.instanceOf(error, DOMException); + assert.strictEqual((error as DOMException).name, "NotAllowedError"); + }); + + it("rejects as soon as the page aborts, without waiting on the system sheet", async () => { + const { credentials } = install(() => new Promise(() => {})); + const controller = new AbortController(); + + const pending = credentials + .get({ publicKey: { challenge: new Uint8Array([1]) }, signal: controller.signal }) + .catch((error: unknown) => error); + controller.abort(); + + const error = await pending; + assert.instanceOf(error, DOMException); + assert.strictEqual((error as DOMException).name, "AbortError"); + }); +}); diff --git a/apps/desktop/src/preview/PasskeyBridge.ts b/apps/desktop/src/preview/PasskeyBridge.ts new file mode 100644 index 000000000000..cea175c29633 --- /dev/null +++ b/apps/desktop/src/preview/PasskeyBridge.ts @@ -0,0 +1,286 @@ +import type { CreateCredentialResult, GetCredentialResult } from "electron-webauthn"; + +import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; + +/** What the main process answers a guest passkey ceremony with. */ +export type PasskeyCeremonyResult = + | Extract + | Extract + | { readonly success: false; readonly error: string }; + +type PasskeyChannel = typeof PASSKEY_CREATE_CHANNEL | typeof PASSKEY_GET_CHANNEL; +// Credential Management Level 1 added `mediation` to creation requests. +type CreationOptions = CredentialCreationOptions & { + readonly mediation?: CredentialMediationRequirement; +}; +type InvokePasskeyCeremony = (channel: PasskeyChannel, publicKey: unknown) => Promise; + +const NOT_ALLOWED_MESSAGE = + "The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client."; +const DOM_EXCEPTION_NAMES = new Set([ + "AbortError", + "InvalidStateError", + "NotAllowedError", + "NotSupportedError", + "SecurityError", +]); + +const fromBase64Url = (value: string) => { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=")); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)).buffer; +}; + +const toBase64Url = (buffer: ArrayBuffer) => + btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); + +const ceremonyError = (name: string) => + name === "TypeError" + ? new TypeError("The passkey request options are invalid.") + : new DOMException( + NOT_ALLOWED_MESSAGE, + DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError", + ); + +/** + * Copies request options into plain IPC-cloneable data. Chromium ignores + * members it does not know, so functions and other uncloneable values drop out + * here instead of failing the whole ceremony. + */ +const toCloneable = (value: unknown): unknown => { + if (value instanceof ArrayBuffer) return value.slice(0); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); + } + if (Array.isArray(value)) return value.map(toCloneable); + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value).flatMap(([key, member]) => + typeof member === "function" || member === undefined ? [] : [[key, toCloneable(member)]], + ), + ); + } + return value; +}; + +// The objects below borrow the native prototypes so `instanceof` checks pass. +// Their own properties shadow the native getters, which only work on objects +// Chromium created itself. +const withValues = (target: T, values: Record) => { + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); + } + return target; +}; + +const prfOutputs = (prf: { + readonly enabled?: boolean; + readonly results?: { readonly first?: string; readonly second?: string }; +}): AuthenticationExtensionsPRFOutputs => { + const first = prf.results?.first; + const second = prf.results?.second; + return { + ...(prf.enabled === undefined ? {} : { enabled: prf.enabled }), + ...(first === undefined + ? {} + : { + results: { + first: fromBase64Url(first), + ...(second === undefined ? {} : { second: fromBase64Url(second) }), + }, + }), + }; +}; + +const extensionResultsJson = (results: AuthenticationExtensionsClientOutputs) => + JSON.parse( + JSON.stringify(results, (_key, value: unknown) => + value instanceof ArrayBuffer ? toBase64Url(value) : value, + ), + ) as unknown; + +const makeCredential = (input: { + readonly id: string; + readonly authenticatorAttachment: AuthenticatorAttachment | null; + readonly response: AuthenticatorResponse; + readonly responseJson: Record; + readonly extensionResults: AuthenticationExtensionsClientOutputs; +}) => + withValues(Object.create(PublicKeyCredential.prototype) as PublicKeyCredential, { + id: input.id, + rawId: fromBase64Url(input.id), + type: "public-key", + authenticatorAttachment: input.authenticatorAttachment, + response: input.response, + getClientExtensionResults: () => input.extensionResults, + toJSON: () => ({ + id: input.id, + rawId: input.id, + type: "public-key", + ...(input.authenticatorAttachment === null + ? {} + : { authenticatorAttachment: input.authenticatorAttachment }), + response: input.responseJson, + clientExtensionResults: extensionResultsJson(input.extensionResults), + }), + }); + +const credentialFromCreateResult = ( + data: Extract["data"], +) => { + const transports = [...data.transports]; + const publicKey = data.publicKey.length > 0 ? data.publicKey : null; + const extensionResults: AuthenticationExtensionsClientOutputs = { + ...(data.extensions.credProps ? { credProps: { ...data.extensions.credProps } } : {}), + ...(data.extensions.prf ? { prf: prfOutputs(data.extensions.prf) } : {}), + ...(data.extensions.largeBlob ? { largeBlob: { ...data.extensions.largeBlob } } : {}), + }; + return makeCredential({ + id: data.credentialId, + // The native layer cannot tell a synced passkey from a security key. + authenticatorAttachment: null, + response: withValues( + Object.create(AuthenticatorAttestationResponse.prototype) as AuthenticatorAttestationResponse, + { + clientDataJSON: fromBase64Url(data.clientDataJSON), + attestationObject: fromBase64Url(data.attestationObject), + getTransports: () => [...transports], + getAuthenticatorData: () => fromBase64Url(data.authData), + getPublicKey: () => (publicKey === null ? null : fromBase64Url(publicKey)), + getPublicKeyAlgorithm: () => data.publicKeyAlgorithm, + }, + ), + responseJson: { + clientDataJSON: data.clientDataJSON, + attestationObject: data.attestationObject, + authenticatorData: data.authData, + transports, + ...(publicKey === null ? {} : { publicKey }), + publicKeyAlgorithm: data.publicKeyAlgorithm, + }, + extensionResults, + }); +}; + +const credentialFromGetResult = (data: Extract["data"]) => { + const userHandle = data.userHandle.length > 0 ? data.userHandle : null; + const { prf, largeBlob } = data.extensions ?? {}; + const extensionResults: AuthenticationExtensionsClientOutputs = { + ...(prf ? { prf: prfOutputs(prf) } : {}), + ...(largeBlob + ? { + largeBlob: { + ...(largeBlob.blob === undefined ? {} : { blob: fromBase64Url(largeBlob.blob) }), + ...(largeBlob.written === undefined ? {} : { written: largeBlob.written }), + }, + } + : {}), + }; + return makeCredential({ + id: data.credentialId, + authenticatorAttachment: null, + response: withValues( + Object.create(AuthenticatorAssertionResponse.prototype) as AuthenticatorAssertionResponse, + { + clientDataJSON: fromBase64Url(data.clientDataJSON), + authenticatorData: fromBase64Url(data.authenticatorData), + signature: fromBase64Url(data.signature), + userHandle: userHandle === null ? null : fromBase64Url(userHandle), + }, + ), + responseJson: { + clientDataJSON: data.clientDataJSON, + authenticatorData: data.authenticatorData, + signature: data.signature, + ...(userHandle === null ? {} : { userHandle }), + }, + extensionResults, + }); +}; + +const abortReason = (signal: AbortSignal) => + signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); + +/** + * Routes this page's WebAuthn ceremonies to the system passkey sheet through + * the main process, which supplies the frame's real origin. Runs in the page's + * own world (the preview preload has contextIsolation off), before any page + * script. Conditional (autofill and automatic upgrade) requests stay native: + * the system sheet is modal and must not open on its own. + */ +export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { + // Insecure contexts have no WebAuthn to bridge. + if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { + return; + } + const run = async ( + channel: PasskeyChannel, + publicKey: unknown, + signal: AbortSignal | undefined, + ): Promise => { + if (signal?.aborted) throw abortReason(signal); + const ceremony = invoke(channel, toCloneable(publicKey)).then( + (value) => { + const result = value as PasskeyCeremonyResult; + if (!result.success) throw ceremonyError(result.error); + return "attestationObject" in result.data + ? credentialFromCreateResult(result.data) + : credentialFromGetResult(result.data); + }, + // No handler yet (or any more) for this guest: answer like a refusal. + (error: unknown) => { + throw error instanceof DOMException || error instanceof TypeError + ? error + : ceremonyError("NotAllowedError"); + }, + ); + if (!signal) return ceremony; + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + ceremony.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); + }; + + const container = CredentialsContainer.prototype; + const nativeCreate = container.create; + const nativeGet = container.get; + const define = (target: object, key: string, value: unknown) => + Object.defineProperty(target, key, { value, configurable: true, writable: true }); + + define( + container, + "create", + function create(this: CredentialsContainer, options?: CreationOptions) { + return options?.publicKey && options.mediation !== "conditional" + ? run(PASSKEY_CREATE_CHANNEL, options.publicKey, options.signal) + : Reflect.apply(nativeCreate, this, [options]); + }, + ); + define( + container, + "get", + function get(this: CredentialsContainer, options?: CredentialRequestOptions) { + return options?.publicKey && options.mediation !== "conditional" + ? run(PASSKEY_GET_CHANNEL, options.publicKey, options.signal) + : Reflect.apply(nativeGet, this, [options]); + }, + ); + + define(PublicKeyCredential, "isUserVerifyingPlatformAuthenticatorAvailable", async () => true); + define(PublicKeyCredential, "isConditionalMediationAvailable", async () => false); + const nativeCapabilities = PublicKeyCredential.getClientCapabilities; + if (typeof nativeCapabilities === "function") { + define(PublicKeyCredential, "getClientCapabilities", async () => ({ + ...(await Reflect.apply(nativeCapabilities, PublicKeyCredential, [])), + conditionalCreate: false, + conditionalGet: false, + hybridTransport: true, + passkeyPlatformAuthenticator: true, + userVerifyingPlatformAuthenticator: true, + })); + } +} diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts new file mode 100644 index 000000000000..0fe4ed8be682 --- /dev/null +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -0,0 +1,374 @@ +import { assert, describe, it } from "@effect/vitest"; +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 { afterEach, beforeEach, vi } from "vite-plus/test"; + +const electron = vi.hoisted(() => ({ + configureWebAuthn: vi.fn(), + showMessageBox: vi.fn(), + fromWebContents: vi.fn(), +})); +const webauthn = vi.hoisted(() => ({ createCredential: vi.fn(), getCredential: vi.fn() })); + +vi.mock("electron", () => ({ + app: { configureWebAuthn: electron.configureWebAuthn }, + dialog: { showMessageBox: electron.showMessageBox }, + BrowserWindow: { fromWebContents: electron.fromWebContents }, + webContents: { fromFrame: () => undefined }, +})); +vi.mock("electron-webauthn", () => webauthn); + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; +import * as PreviewPasskeys from "./Passkeys.ts"; + +const layerFor = (packageJson: object, isPackaged = true) => + PreviewPasskeys.layer.pipe( + Layer.provide( + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform: "darwin", + isPackaged, + appRoot: "/app", + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + ), + Layer.provide( + FileSystem.layerNoop({ + readFileString: () => Effect.succeed(JSON.stringify(packageJson)), + }), + ), + ); + +type Handler = (event: { readonly senderFrame: object | null }, publicKey: unknown) => unknown; + +const makeGuest = (origin = "https://accounts.example.com") => { + const handlers = new Map(); + const state = { focused: true }; + const mainFrame = { origin, isDestroyed: () => false }; + const guest = { + mainFrame, + hostWebContents: {}, + isFocused: () => state.focused, + ipc: { + handle: (channel: string, handler: Handler) => handlers.set(channel, handler), + removeHandler: (channel: string) => handlers.delete(channel), + }, + }; + const call = (channel: string, publicKey: unknown, senderFrame: object | null = mainFrame) => + Effect.promise(async () => handlers.get(channel)?.({ senderFrame }, publicKey)); + return { guest: guest as unknown as Electron.WebContents, mainFrame, handlers, state, call }; +}; + +const bridgeLayer = layerFor({ t3codeWebAuthn: { browserPasskeys: true } }); + +/** CBOR `{ fmt: "packed", attStmt: { alg: -7, sig }, authData }`, as authenticators encode it. */ +const attestationObject = (authData: ReadonlyArray) => { + const text = (value: string) => [0x60 + value.length, ...Buffer.from(value)]; + const bytes = (value: ReadonlyArray) => [0x58, value.length, ...value]; + return Buffer.from([ + 0xa3, + ...text("fmt"), + ...text("packed"), + ...text("attStmt"), + 0xa2, + ...text("alg"), + 0x26, + ...text("sig"), + ...bytes(Array.from({ length: 70 }, () => 7)), + ...text("authData"), + ...bytes(authData), + ]).toString("base64url"); +}; + +describe("PreviewPasskeys", () => { + beforeEach(() => { + vi.clearAllMocks(); + electron.fromWebContents.mockReturnValue({ + isDestroyed: () => false, + getNativeWindowHandle: () => Buffer.from([1]), + }); + }); + // Restored here so a failing fake-timer test cannot leak fake timers into the next one. + afterEach(() => { + vi.useRealTimers(); + }); + + it.effect("pins each guest ceremony to the origin of the frame that asked", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const { guest, handlers, call } = makeGuest(); + webauthn.getCredential.mockResolvedValue({ + success: false, + error: "NotAllowedError", + errorObject: new Error("native detail"), + }); + + const detach = passkeys.attachGuest(guest); + const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" }; + const result = yield* call(PASSKEY_GET_CHANNEL, publicKey); + + assert.deepStrictEqual(result, { success: false, error: "NotAllowedError" }); + const [sentPublicKey, options] = webauthn.getCredential.mock.calls[0] ?? []; + assert.deepStrictEqual(sentPublicKey, publicKey); + assert.strictEqual(options.currentOrigin, "https://accounts.example.com"); + assert.strictEqual(options.topFrameOrigin, "https://accounts.example.com"); + assert.isTrue(options.isPublicSuffix("com")); + assert.isTrue(options.isPublicSuffix("github.io")); + assert.isFalse(options.isPublicSuffix("example.com")); + assert.isFalse(options.isPublicSuffix("localhost")); + + // WebAuthn defaults the RP ID to the caller's own host. + yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); + assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); + + // Only the main frame runs the preload; anything else is not the page. + const forged = yield* call(PASSKEY_CREATE_CHANNEL, publicKey, { + origin: "https://evil.example", + }); + assert.deepStrictEqual(forged, { success: false, error: "NotAllowedError" }); + assert.strictEqual(webauthn.createCredential.mock.calls.length, 0); + + detach(); + assert.strictEqual(handlers.size, 0); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("registers ES256 keys only and reports their real authenticator data", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const { guest, call } = makeGuest(); + passkeys.attachGuest(guest); + webauthn.createCredential.mockResolvedValue({ + success: true, + data: { + credentialId: "AQ", + clientDataJSON: "Ag", + attestationObject: attestationObject([1, 2, 3, 4]), + authData: Buffer.from('{"parsed":true}').toString("base64url"), + publicKey: "BQ", + publicKeyAlgorithm: -7, + transports: ["hybrid", "internal"], + extensions: {}, + }, + }); + const publicKey = { + challenge: new Uint8Array([1]), + pubKeyCredParams: [ + { type: "public-key", alg: -8 }, + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 }, + ], + }; + + const result = yield* call(PASSKEY_CREATE_CHANNEL, publicKey); + + assert.deepStrictEqual(webauthn.createCredential.mock.calls[0]?.[0].pubKeyCredParams, [ + { type: "public-key", alg: -7 }, + ]); + assert.deepInclude(result, { success: true }); + const data = (result as { readonly data: Record }).data; + assert.strictEqual(data.authData, Buffer.from([1, 2, 3, 4]).toString("base64url")); + assert.deepStrictEqual(data.transports, []); + + const unsupported = yield* call(PASSKEY_CREATE_CHANNEL, { + ...publicKey, + pubKeyCredParams: [{ type: "public-key", alg: -8 }], + }); + assert.deepStrictEqual(unsupported, { success: false, error: "NotSupportedError" }); + assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); + + // A malformed algorithm list is the page's error, not a cue to pick ES256. + const malformed = yield* call(PASSKEY_CREATE_CHANNEL, { + ...publicKey, + pubKeyCredParams: { type: "public-key", alg: -7 }, + }); + assert.deepStrictEqual(malformed, { success: false, error: "TypeError" }); + assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); + + // Without authenticator data the site cannot verify the passkey. + webauthn.createCredential.mockResolvedValueOnce({ + success: true, + data: { credentialId: "AQ", attestationObject: Buffer.from([0x00]).toString("base64url") }, + }); + assert.deepStrictEqual(yield* call(PASSKEY_CREATE_CHANNEL, publicKey), { + success: false, + error: "NotAllowedError", + }); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("answers only with a credential the site allowed", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const { guest, call } = makeGuest(); + passkeys.attachGuest(guest); + const assertion = (credentialId: string) => ({ + success: true, + data: { + credentialId, + clientDataJSON: "Ag", + authenticatorData: "Aw", + signature: "BA", + userHandle: "BQ", + }, + }); + const publicKey = { + challenge: new Uint8Array([1]), + rpId: "example.com", + allowCredentials: [{ type: "public-key", id: new Uint8Array([1]) }], + }; + + // macOS applies the allow list to passkeys but not to security keys. + webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); + const other = yield* call(PASSKEY_GET_CHANNEL, publicKey); + assert.deepStrictEqual(other, { success: false, error: "NotAllowedError" }); + + webauthn.getCredential.mockResolvedValueOnce(assertion("AQ")); + assert.deepInclude(yield* call(PASSKEY_GET_CHANNEL, publicKey), { success: true }); + + // With no allow list, any of the site's credentials will do. + webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); + const discoverable = yield* call(PASSKEY_GET_CHANNEL, { + challenge: new Uint8Array([1]), + rpId: "example.com", + }); + assert.deepInclude(discoverable, { success: true }); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("opens the sheet only for a focused, secure page with nothing else pending", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const notAllowed = { success: false, error: "NotAllowedError" }; + const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" }; + + const lan = makeGuest("http://192.168.1.5:3000"); + passkeys.attachGuest(lan.guest); + assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); + // macOS refuses `*.localhost`, so the page hears no rather than a native error. + const subdomain = makeGuest("http://app.localhost:3000"); + passkeys.attachGuest(subdomain.guest); + assert.deepStrictEqual(yield* subdomain.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); + + const page = makeGuest(); + passkeys.attachGuest(page.guest); + page.state.focused = false; + assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); + assert.strictEqual(webauthn.getCredential.mock.calls.length, 0); + + page.state.focused = true; + let finish: (value: unknown) => void = () => {}; + webauthn.getCredential.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + const first = yield* Effect.forkChild(page.call(PASSKEY_GET_CHANNEL, publicKey)); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); + + // The page navigated while the sheet was up; its credential goes nowhere. + page.mainFrame.origin = "https://other.example"; + finish({ success: true, data: { credentialId: "AQ" } }); + assert.deepStrictEqual(yield* Fiber.join(first), notAllowed); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("answers the page even when the native ceremony never settles", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const { guest, call } = makeGuest(); + passkeys.attachGuest(guest); + webauthn.getCredential.mockReturnValueOnce(new Promise(() => {})); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const pending = yield* Effect.forkChild( + call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]), timeout: 1_000 }), + ); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000)); + + assert.deepStrictEqual(yield* Fiber.join(pending), { + success: false, + error: "NotAllowedError", + }); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("turns each path on only for builds signed for it", () => + Effect.gen(function* () { + const entitled = yield* PreviewPasskeys.PreviewPasskeys.pipe( + Effect.provide( + layerFor({ + t3codeWebAuthn: { + touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn", + browserPasskeys: false, + }, + }), + ), + ); + yield* entitled.configure; + assert.deepStrictEqual(electron.configureWebAuthn.mock.calls, [ + [{ touchID: { keychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn" } }], + ]); + assert.isFalse(entitled.bridgeEnabled); + const { guest, handlers } = makeGuest(); + entitled.attachGuest(guest); + assert.strictEqual(handlers.size, 0); + + const unpackaged = yield* PreviewPasskeys.PreviewPasskeys.pipe( + Effect.provide( + layerFor( + { t3codeWebAuthn: { touchIdKeychainAccessGroup: "X", browserPasskeys: true } }, + false, + ), + ), + ); + yield* unpackaged.configure; + assert.strictEqual(electron.configureWebAuthn.mock.calls.length, 1); + assert.isFalse(unpackaged.bridgeEnabled); + }), + ); + + it.effect("asks which passkey to use only when a site has several", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const listeners: Array<(...args: ReadonlyArray) => void> = []; + const session = { + on: (_event: string, listener: (...args: ReadonlyArray) => void) => + listeners.push(listener), + } as unknown as Electron.Session; + passkeys.installSessionHandlers(session); + passkeys.installSessionHandlers(session); + assert.strictEqual(listeners.length, 1); + + const select = (accounts: ReadonlyArray) => + Effect.promise( + () => + new Promise((resolve) => + listeners[0]?.( + {}, + { relyingPartyId: "example.com", accounts, frame: null }, + (credentialId?: string) => resolve(credentialId), + ), + ), + ); + const alice = { credentialId: "alice", name: "alice@example.com" }; + const bob = { credentialId: "bob", displayName: "Bob", name: "bob@example.com" }; + + assert.strictEqual(yield* select([alice]), "alice"); + assert.strictEqual(electron.showMessageBox.mock.calls.length, 0); + + electron.showMessageBox.mockResolvedValueOnce({ response: 1 }); + assert.strictEqual(yield* select([alice, bob]), "bob"); + assert.deepStrictEqual(electron.showMessageBox.mock.calls[0]?.[0]?.buttons, [ + "alice@example.com", + "Bob (bob@example.com)", + "Cancel", + ]); + + electron.showMessageBox.mockResolvedValueOnce({ response: 2 }); + assert.isUndefined(yield* select([alice, bob])); + }).pipe(Effect.provide(layerFor({}))), + ); +}); diff --git a/apps/desktop/src/preview/Passkeys.ts b/apps/desktop/src/preview/Passkeys.ts new file mode 100644 index 000000000000..c06b4dc522b5 --- /dev/null +++ b/apps/desktop/src/preview/Passkeys.ts @@ -0,0 +1,242 @@ +// @effect-diagnostics globalTimers:off - Ceremonies settle inside IPC handlers, outside an Effect runtime. +import type { IpcMainInvokeEvent, Session, WebContents, WebFrameMain } from "electron"; +import { app, BrowserWindow, dialog, webContents as electronWebContents } from "electron"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; +import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts"; +import { macPasskeyBackend } from "./PasskeyBackendMac.ts"; +import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; + +const PasskeyPackageMetadata = Schema.Struct({ + t3codeWebAuthn: Schema.optional( + Schema.Struct({ + touchIdKeychainAccessGroup: Schema.optional(Schema.String), + browserPasskeys: Schema.optional(Schema.Boolean), + }), + ), +}); +const decodePasskeyPackageMetadata = Schema.decodeEffect( + Schema.fromJsonString(PasskeyPackageMetadata), +); + +export class PreviewPasskeysConfigureError extends Schema.TaggedError()( + "PreviewPasskeysConfigureError", + { + keychainAccessGroup: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to enable Touch ID passkeys for the in-app browser (keychain group ${this.keychainAccessGroup}).`; + } +} + +/** + * Passkeys for in-app browser pages on macOS, where Electron has no WebAuthn UI + * of its own. Signed builds record which entitlements their provisioning + * profile granted (scripts/build-desktop-artifact.ts), and each path turns on + * only when its entitlement is present: + * + * - Touch ID: Electron's built-in platform authenticator. Passkeys it creates + * stay on this Mac, in T3 Code's keychain group. + * - Browser passkeys: Apple's managed browser entitlement lets the system sheet + * (iCloud Keychain, password managers, phones, security keys) serve any + * site. The guest preload hands each ceremony to this process, which pins + * the requesting frame's real origin. + * + * Windows needs neither: Chromium already uses Windows Hello there. + */ +export class PreviewPasskeys extends Context.Service< + PreviewPasskeys, + { + /** Whether preview pages route WebAuthn through the system passkey sheet. */ + readonly bridgeEnabled: boolean; + /** Turns on Touch ID passkeys when this build is entitled to them. Runs after app ready. */ + readonly configure: Effect.Effect; + /** Lets the user choose between several passkeys for one site. Idempotent. */ + readonly installSessionHandlers: (session: Session) => void; + /** Serves a preview guest's passkey ceremonies. Returns the detach function. */ + readonly attachGuest: (guest: WebContents) => () => void; + } +>()("@t3tools/desktop/preview/Passkeys/PreviewPasskeys") {} + +const notAllowed: PasskeyCeremonyResult = { success: false, error: "NotAllowedError" }; + +const accountLabel = (account: Electron.WebAuthnAccount) => + account.displayName && account.name && account.displayName !== account.name + ? `${account.displayName} (${account.name})` + : (account.name ?? account.displayName ?? "Unnamed passkey"); + +const ownerWindow = (frame: WebFrameMain | null) => { + const contents = frame ? electronWebContents.fromFrame(frame) : undefined; + const owner = contents?.hostWebContents ?? contents; + return owner ? BrowserWindow.fromWebContents(owner) : null; +}; + +const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => { + const [onlyAccount] = details.accounts; + // The Touch ID prompt that follows already asks the user to confirm. + if (details.accounts.length === 1) return onlyAccount?.credentialId; + const options: Electron.MessageBoxOptions = { + type: "none", + message: "Choose a passkey", + detail: details.relyingPartyId, + buttons: [...details.accounts.map(accountLabel), "Cancel"], + cancelId: details.accounts.length, + defaultId: 0, + noLink: true, + }; + const parent = ownerWindow(details.frame); + const { response } = parent + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options); + return details.accounts[response]?.credentialId; +}; + +const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000; + +// Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony +// unsettled, and the page must still get an answer. +const ceremonyDeadline = (timeout: unknown) => + (typeof timeout === "number" && timeout > 0 + ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS) + : NATIVE_DEFAULT_TIMEOUT_MS) + 5_000; + +const withDeadline = (ceremony: Promise, milliseconds: number) => { + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(notAllowed), milliseconds); + }); + return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer)); +}; + +// Secure contexts only: https, or http on this machine. Chromium also trusts +// `*.localhost`, but the macOS layer refuses it, so it is turned away here +// instead of failing deeper with a less useful error. +const isTrustworthyOrigin = (origin: string) => { + if (!URL.canParse(origin)) return false; + const { protocol, hostname } = new URL(origin); + return ( + protocol === "https:" || + (protocol === "http:" && + (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")) + ); +}; + +/** + * The platform that serves preview pages' passkey ceremonies, or none. Windows + * needs none: Chromium already hands WebAuthn to Windows Hello there. + */ +const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => + platform === "darwin" ? macPasskeyBackend : undefined; + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + + const metadata = + environment.platform === "darwin" && environment.isPackaged + ? yield* fileSystem + .readFileString(environment.path.join(environment.appRoot, "package.json")) + .pipe( + Effect.flatMap(decodePasskeyPackageMetadata), + Effect.map((parsed) => parsed.t3codeWebAuthn), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + const keychainAccessGroup = metadata?.touchIdKeychainAccessGroup; + const backend = metadata?.browserPasskeys === true ? backendFor(environment.platform) : undefined; + const bridgeEnabled = backend !== undefined; + const sessionsWithHandlers = new WeakSet(); + + return PreviewPasskeys.of({ + bridgeEnabled, + configure: Effect.gen(function* () { + if (keychainAccessGroup === undefined) return; + yield* Effect.try({ + try: () => app.configureWebAuthn({ touchID: { keychainAccessGroup } }), + catch: (cause) => new PreviewPasskeysConfigureError({ keychainAccessGroup, cause }), + }).pipe(Effect.catch((error) => Effect.logWarning(error.message, { cause: error.cause }))); + }).pipe(Effect.withSpan("desktop.previewPasskeys.configure")), + installSessionHandlers: (session) => { + if (sessionsWithHandlers.has(session)) return; + sessionsWithHandlers.add(session); + session.on("select-webauthn-account", (_event, details, callback) => { + // The request stays pending until the callback runs, so it must run once. + void chooseAccount(details).then( + (credentialId) => callback(credentialId), + () => callback(), + ); + }); + }, + attachGuest: (guest) => { + if (backend === undefined) return () => {}; + let ceremonyPending = false; + const serve = async ( + event: IpcMainInvokeEvent, + publicKey: { readonly timeout?: unknown } | undefined, + ceremony: (context: PasskeyCeremonyContext) => Promise, + ): Promise => { + // Only the guest's main frame runs the bridge preload. + const frame = event.senderFrame; + if (!frame || frame !== guest.mainFrame) return notAllowed; + // The page shares a JS world with the preload, so nothing it sends can + // be trusted for the origin. Read the committed origin before any await: + // the frame object outlives a navigation. + const origin = frame.origin; + // Like Chromium, only a focused page may open the sheet, one at a time. + if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { + return notAllowed; + } + if (typeof publicKey !== "object" || publicKey === null) { + return { success: false, error: "TypeError" }; + } + const host = guest.hostWebContents + ? BrowserWindow.fromWebContents(guest.hostWebContents) + : null; + if (!host || host.isDestroyed()) return notAllowed; + + ceremonyPending = true; + try { + const result = await withDeadline( + ceremony({ origin, nativeWindowHandle: host.getNativeWindowHandle() }), + ceremonyDeadline(publicKey.timeout), + ); + // A credential minted for a document that navigated away belongs to nobody. + const sameDocument = + !frame.isDestroyed() && frame === guest.mainFrame && frame.origin === origin; + return sameDocument ? result : notAllowed; + } catch { + return notAllowed; + } finally { + ceremonyPending = false; + } + }; + const detach = () => { + guest.ipc.removeHandler(PASSKEY_CREATE_CHANNEL); + guest.ipc.removeHandler(PASSKEY_GET_CHANNEL); + }; + detach(); + // Option shapes are the page's to get wrong: the native layer validates + // them and answers with a TypeError. + guest.ipc.handle( + PASSKEY_CREATE_CHANNEL, + (event, publicKey: PublicKeyCredentialCreationOptions) => + serve(event, publicKey, (context) => backend.create(publicKey, context)), + ); + guest.ipc.handle(PASSKEY_GET_CHANNEL, (event, publicKey: PublicKeyCredentialRequestOptions) => + serve(event, publicKey, (context) => backend.get(publicKey, context)), + ); + return detach; + }, + }); +}).pipe(Effect.withSpan("PreviewPasskeys.make")); + +export const layer = Layer.effect(PreviewPasskeys, make); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index a656070f87b4..37c7b99dcb53 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -316,12 +316,43 @@ function createBox(color: string, fill: string): HTMLDivElement { } function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { + if (rect.width <= 0 || rect.height <= 0) { + node.style.display = "none"; + return; + } node.style.display = "block"; node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; node.style.width = `${rect.width}px`; node.style.height = `${rect.height}px`; } +/** Clamps a box to the viewport and clipping ancestors, so its border is never painted off-screen. */ +function visibleElementRect(element: Element): PreviewAnnotationRect { + const rect = element.getBoundingClientRect(); + let left = Math.max(0, rect.left); + let top = Math.max(0, rect.top); + let right = Math.min(document.documentElement.clientWidth, rect.right); + let bottom = Math.min(document.documentElement.clientHeight, rect.bottom); + if (getComputedStyle(element).position !== "fixed") { + for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { + if (ancestor === document.body || ancestor === document.documentElement) break; + const style = getComputedStyle(ancestor); + const bounds = ancestor.getBoundingClientRect(); + const clipLeft = bounds.left + ancestor.clientLeft; + const clipTop = bounds.top + ancestor.clientTop; + if (style.overflowX !== "visible") { + left = Math.max(left, clipLeft); + right = Math.min(right, clipLeft + ancestor.clientWidth); + } + if (style.overflowY !== "visible") { + top = Math.max(top, clipTop); + bottom = Math.min(bottom, clipTop + ancestor.clientHeight); + } + } + } + return { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }; +} + function createLabel(): HTMLDivElement { const label = document.createElement("div"); label.setAttribute(OVERLAY_ATTRIBUTE, ""); @@ -343,11 +374,15 @@ function updateSelectedVisual(target: SelectedElement): void { target.label.style.display = "none"; return; } - const rect = target.element.getBoundingClientRect(); - positionBox(target.outline, rectFromDomRect(rect)); + const rect = visibleElementRect(target.element); + positionBox(target.outline, rect); + if (rect.width === 0 || rect.height === 0) { + target.label.style.display = "none"; + return; + } target.label.textContent = describeRawElement(target.element); target.label.style.display = "block"; - target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; + target.label.style.transform = `translate(${Math.max(4, rect.x)}px, ${Math.max(4, rect.y - 22)}px)`; } function toStackFrame(frame: { @@ -995,8 +1030,9 @@ function startAnnotation(sendEnabled: boolean): void { const getAnnotationBounds = (): PreviewAnnotationRect | null => unionRects( [ - ...Array.from(selected.values(), (target) => - rectFromDomRect(target.element.getBoundingClientRect()), + // Follow the visible outline, so a clipped selection does not anchor the editor off-screen. + ...Array.from(selected.values(), (target) => visibleElementRect(target.element)).filter( + (rect) => rect.width > 0 && rect.height > 0, ), ...regions.map((region) => region.rect), ...strokes.map((stroke) => stroke.bounds), @@ -1108,6 +1144,8 @@ function startAnnotation(sendEnabled: boolean): void { for (const target of selected.values()) updateSelectedVisual(target); queueEditorLayout(); }; + // A scrollbar appearing narrows the viewport without a window resize. + const rootResizeObserver = new ResizeObserver(repaint); const removeTargetAtPoint = (x: number, y: number): boolean => { for (const target of Array.from(selected.values()).toReversed()) { @@ -1192,7 +1230,7 @@ function startAnnotation(sendEnabled: boolean): void { } if (tool === "select" && dragStart === null) { const target = pickFromPoint(event.clientX, event.clientY); - if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); + if (target) positionBox(hoverOutline, visibleElementRect(target)); else clearHoverOutline(); return; } @@ -1310,6 +1348,7 @@ function startAnnotation(sendEnabled: boolean): void { const teardown = (notifyMain: boolean): void => { if (finished) return; finished = true; + rootResizeObserver.disconnect(); restoreStyles(); window.removeEventListener("pointermove", onPointerMove, true); window.removeEventListener("pointerdown", onPointerDown, true); @@ -1445,6 +1484,7 @@ function startAnnotation(sendEnabled: boolean): void { ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); document.documentElement.appendChild(host); + rootResizeObserver.observe(document.documentElement); refreshToolButtons(); updateStatus(); activeSession = { diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts index 0b9346a9f8cf..248a6874e48f 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts @@ -29,7 +29,7 @@ beforeEach(async () => { tools.validateNiri.mockReset().mockResolvedValue(undefined); tools.hyprlandBindings.mockReset().mockResolvedValue([]); tools.reloadHyprland.mockReset().mockResolvedValue(undefined); - setup = new CaptureShortcutConfig(tools); + setup = new CaptureShortcutConfig(NodeOS.homedir(), tools); }); afterEach(async () => { await NodeFSP.rm(directory, { recursive: true, force: true }); @@ -219,15 +219,15 @@ it("does not edit Omarchy's shipped defaults", async () => { }); it("honors NIRI_CONFIG and XDG_CONFIG_HOME", () => { expect( - niriCaptureConfigPath( - { NIRI_CONFIG: "/custom/test.kdl", XDG_CONFIG_HOME: "/config" }, - "/home/test", - ), + niriCaptureConfigPath("/home/test", { + NIRI_CONFIG: "/custom/test.kdl", + XDG_CONFIG_HOME: "/config", + }), ).toBe("/custom/test.kdl"); - expect(niriCaptureConfigPath({ XDG_CONFIG_HOME: "/config" }, "/home/test")).toBe( + expect(niriCaptureConfigPath("/home/test", { XDG_CONFIG_HOME: "/config" })).toBe( "/config/niri/config.kdl", ); - expect(niriCaptureConfigPath({}, "/home/test")).toBe("/home/test/.config/niri/config.kdl"); + expect(niriCaptureConfigPath("/home/test", {})).toBe("/home/test/.config/niri/config.kdl"); }); it("preserves a UTF-8 BOM through preview and apply", async () => { await NodeFSP.writeFile(path, "\uFEFFbinds {}\r\n"); @@ -251,7 +251,7 @@ it("the installed Niri parser accepts the generated edit and its relative includ NodePath.join(directory, "keys.kdl"), "binds {\n Ctrl+Alt+Q { quit; }\n}\n", ); - const checked = new CaptureShortcutConfig({ + const checked = new CaptureShortcutConfig(NodeOS.homedir(), { ...tools, validateNiri: async (file) => { await NodeUtil.promisify(NodeChildProcess.execFile)("niri", ["validate", "--config", file]); diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.ts index edc4c300c101..8b86b416cdba 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.ts @@ -2,7 +2,6 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; -import * as NodeOS from "node:os"; import * as NodeCrypto from "node:crypto"; import * as NodeChildProcess from "node:child_process"; import * as NodeUtil from "node:util"; @@ -39,7 +38,7 @@ const decodeHyprlandBindings = Schema.decodeUnknownSync( ), ); -export function niriCaptureConfigPath(env = process.env, home = NodeOS.homedir()) { +export function niriCaptureConfigPath(home: string, env = process.env) { return ( env.NIRI_CONFIG || NodePath.join(env.XDG_CONFIG_HOME || NodePath.join(home, ".config"), "niri", "config.kdl") @@ -99,8 +98,10 @@ export class CaptureShortcutConfig { | { preview: DesktopCaptureConfigPreview; target: Target; files: Snapshot[]; missing: string[] } | undefined; private applying = false; + private readonly home: string; private readonly tools: typeof defaultTools; - constructor(tools = defaultTools) { + constructor(home: string, tools = defaultTools) { + this.home = home; this.tools = tools; } @@ -166,7 +167,7 @@ export class CaptureShortcutConfig { if (include.path.includes("$") || /[*?[\]]/.test(include.path)) throw new Error("This config uses a dynamic include. Use manual setup in Advanced."); const path = include.path.startsWith("~/") - ? NodePath.join(NodeOS.homedir(), include.path.slice(2)) + ? NodePath.join(this.home, include.path.slice(2)) : NodePath.resolve(NodePath.dirname(file.path), include.path); let child: Snapshot; try { diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index f1c1c2a4908f..71fe47d3f6ca 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -88,6 +88,7 @@ import { snapShotShortcutRegistrationFailureMessage, snapShotShortcutSystemConflict, } from "./snapShot.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const MAX_CAPTURE_WIDTH = 2_560; const MAX_CAPTURE_HEIGHT = 1_600; @@ -727,6 +728,7 @@ export const make = Effect.gen(function* () { const runPromise = Effect.runPromiseWith(context); const captureDirectory = path.join(environment.stateDir, "snap-shots"); const linuxAppId = environment.linuxDesktopEntryName.replace(/\.desktop$/, ""); + const home = yield* HostProcess.HomeDirectory; let shortcutVerified = false; const gnomeSetupPaths = { bundle: environment.isPackaged @@ -757,7 +759,7 @@ export const make = Effect.gen(function* () { dataHome: path.dirname(environment.linuxApplicationsDir), }; const shiftShortcutWorkerPath = path.join(__dirname, "snapShot", "GlobalShiftShortcutWorker.cjs"); - const shortcutConfig = new CaptureShortcutConfig(); + const shortcutConfig = new CaptureShortcutConfig(home); const accessibilityWorkerPath = path.join( __dirname, "snapShot", @@ -1184,7 +1186,7 @@ export const make = Effect.gen(function* () { shortcut, shortcutRegistered: false, shortcutBinding: niriCaptureBinding(linuxAppId), - shortcutConfigPath: niriCaptureConfigPath(), + shortcutConfigPath: niriCaptureConfigPath(home), shortcutActionRegistered: registered, shortcutMessage: registered ? "Set up the shortcut to add it to your Niri config." @@ -1474,8 +1476,8 @@ export const make = Effect.gen(function* () { const configPath = selectedPath ?? (desktop === "niri" - ? niriCaptureConfigPath() - : (await hyprlandCaptureShortcut(linuxAppId)).shortcutConfigPath); + ? niriCaptureConfigPath(home) + : (await hyprlandCaptureShortcut(linuxAppId, home)).shortcutConfigPath); return shortcutConfig.preview({ desktop, path: configPath, appId: linuxAppId }, request); }, catch: (cause) => @@ -1593,7 +1595,7 @@ export const make = Effect.gen(function* () { : undefined; const hyprlandShortcut = state.linuxBackend === "hyprland" - ? yield* Effect.promise(() => hyprlandCaptureShortcut(linuxAppId)) + ? yield* Effect.promise(() => hyprlandCaptureShortcut(linuxAppId, home)) : undefined; return { ...state, @@ -1609,7 +1611,7 @@ export const make = Effect.gen(function* () { : {}), ...hyprlandShortcut, ...(state.linuxBackend === "niri" - ? { shortcutConfigPath: niriCaptureConfigPath() } + ? { shortcutConfigPath: niriCaptureConfigPath(home) } : {}), ...(kdeHelper ? { diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts index 3129d5bab7fd..fe55319ecea4 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts @@ -121,7 +121,7 @@ it.each([true, false])("uses the active config syntax and user bindings (Lua: %s NodePath.join(config, lua ? "bindings.lua" : "bindings.conf"), "custom bindings", ); - const result = await hyprlandCaptureShortcut("com.t3tools.T3Code", directory); + const result = await hyprlandCaptureShortcut("com.t3tools.T3Code", "/home/test", directory); expect(result.shortcutConfigPath).toBe( NodePath.join(config, lua ? "bindings.lua" : "bindings.conf"), ); diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.ts index 2fc90a410a37..4dc57e44448d 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.ts @@ -29,7 +29,8 @@ function hyprlandCaptureBinding(appId: string, lua: boolean): string { /** Omarchy owns its defaults; instructions always point at a user-owned config. */ export async function hyprlandCaptureShortcut( appId: string, - configHome = process.env.XDG_CONFIG_HOME || NodePath.join(NodeOS.homedir(), ".config"), + home: string, + configHome = process.env.XDG_CONFIG_HOME || NodePath.join(home, ".config"), ) { const directory = NodePath.join(configHome, "hypr"); const exists = async (name: string) => diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 634c4653a5a6..ea52e877b93f 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -1,5 +1,5 @@ import { it as effectIt } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -703,7 +703,7 @@ describe("isWaylandSession", () => { "falls back to a live runtime directory socket when session variables are stripped", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; yield* Effect.promise(async () => { const { mkdtemp, rm, writeFile } = await import("node:fs/promises"); const { createServer } = await import("node:net"); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 10c3cb8adee9..9e4ed5ca434a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -56,6 +56,7 @@ import { import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as PreviewPasskeys from "../preview/Passkeys.ts"; const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -325,6 +326,11 @@ function layerTest(input: { } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, layerElectronWindow, + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, @@ -433,6 +439,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6dede819356a..07942b67356b 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -25,7 +25,9 @@ import { TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; +import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -326,6 +328,7 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; @@ -537,6 +540,12 @@ export const make = Effect.gen(function* () { webPreferences.nodeIntegrationInSubFrames = false; webPreferences.contextIsolation = false; webPreferences.disableHtmlFullscreenWindowResize = true; + if (previewPasskeys.bridgeEnabled) { + webPreferences.additionalArguments = [ + ...(webPreferences.additionalArguments ?? []), + PASSKEY_BRIDGE_ARGUMENT, + ]; + } }); const contextMenuContents = new WeakSet(); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 82af7bc907fb..fd575ccd7026 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -265,6 +265,11 @@ const config: ExpoConfig = { "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], }, infoPlist: { + UISupportedInterfaceOrientations: [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ], NSAppTransportSecurity: { NSAllowsArbitraryLoads: true, }, diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.17.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.17.tgz new file mode 100644 index 000000000000..27073e7da6a4 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.17.tgz differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz deleted file mode 100644 index 860521672c8a..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz and /dev/null differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index ac00a91e91fe..2947275d106e 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -13,7 +13,10 @@ }, "channel": "development", "developmentClient": true, - "distribution": "internal" + "distribution": "internal", + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" + } }, "preview": { "corepack": true, @@ -23,7 +26,10 @@ }, "channel": "preview", "environment": "preview", - "distribution": "internal" + "distribution": "internal", + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" + } }, "preview:dev": { "corepack": true, @@ -38,6 +44,9 @@ "distribution": "internal", "android": { "buildType": "apk" + }, + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" } }, "v2-preview": { @@ -56,7 +65,10 @@ }, "channel": "production", "environment": "production", - "autoIncrement": true + "autoIncrement": true, + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" + } } }, "submit": { diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 8481d61cb5b6..0c37ea9a24ad 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -1,7 +1,8 @@ { "platforms": ["apple", "android"], "apple": { - "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] + "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"], + "appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"] }, "android": { "modules": [ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index 3be5df2eb547..2d92537bf431 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module { public final class T3KeyboardCommandsView: ExpoView { let onCommand = EventDispatcher() private var enabledCommands = Set() + private let shortcutInputView = UIView(frame: .zero) public override var canBecomeFirstResponder: Bool { true } + // This responder handles hardware shortcuts, so reclaiming it must not show a software keyboard. + public override var inputView: UIView? { shortcutInputView } + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss), let input = window?.t3FirstResponder as? UITextInput, @@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView { public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) + inputAssistantItem.leadingBarButtonGroups = [] + inputAssistantItem.trailingBarButtonGroups = [] NotificationCenter.default.addObserver( self, selector: #selector(reclaimFirstResponderIfAvailable), diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift index 43070a614582..4e0fcf6d2206 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -1,10 +1,12 @@ import ExpoModulesCore import UIKit -/// Reports the body geometry of a native navigation column. +/// Reports geometry in the observing view's coordinate space, including system +/// reservations that safe-area rectangles cannot represent, such as the fold. final class T3LayoutMetricsView: ExpoView { let onMetricsChange = EventDispatcher() private var lastMetrics: NSDictionary? + private var hasHinge: Bool? = false required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -12,6 +14,22 @@ final class T3LayoutMetricsView: ExpoView { registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in view.publishMetrics() } + #if compiler(>=6.4) + if #available(iOS 27.1, *) { + hasHinge = nil + addInteraction(UIHingeInteraction { [weak self] _, update in + guard let self, self.window != nil else { return } + // Capability survives temporary detachment and does not follow the hinge angle. + let hasHinge = self.hasHinge == true || update.hinge != nil + guard self.hasHinge != hasHinge else { return } + self.hasHinge = hasHinge + self.publishMetrics() + }) + registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3LayoutMetricsView, _: UITraitCollection) in + view.publishMetrics() + } + } + #endif } override func didMoveToWindow() { @@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView { } private func publishMetrics() { - guard window != nil, bounds.width > 0, bounds.height > 0 else { return } + guard let window, bounds.width > 0, bounds.height > 0, let hasHinge else { return } + if hasHinge { + T3WorkspaceOrientationSubscriber.allowHingeOrientations(in: window) + } + var verticalBarEdge = "none" + var regions: [[String: Any]] = [] + #if compiler(>=6.4) + if #available(iOS 27.1, *) { + let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft + switch traitCollection.verticalBarEdge { + case .leading: verticalBarEdge = rtl ? "right" : "left" + case .trailing: verticalBarEdge = rtl ? "left" : "right" + default: break + } + for kind in [UIView.ReservedRegion.Kind.division, .occlusion] { + for region in reservedRegions(kind: kind) where region.isActive { + let frame = region.frame.intersection(bounds) + guard !frame.isNull, !frame.isEmpty else { continue } + regions.append([ + "kind": kind == .division ? "division" : "occlusion", + "x": frame.minX, "y": frame.minY, + "width": frame.width, "height": frame.height, + ]) + } + } + } + #endif let metrics: [String: Any] = [ "width": bounds.width, "height": bounds.height, + "hasHinge": hasHinge, "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", + "verticalBarEdge": verticalBarEdge, "safeArea": [ "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, "left": safeAreaInsets.left, "right": safeAreaInsets.right, ], + "reservedRegions": regions, ] let next = metrics as NSDictionary guard lastMetrics?.isEqual(next) != true else { return } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 3749badcd5ea..32f5df0f50a5 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -13,7 +13,9 @@ public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Constants { if #available(iOS 26.0, *) { - return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] + return [ + "supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil, + ] } return ["supportsWorkspaceColumns": false] } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3WorkspaceOrientationSubscriber.swift b/apps/mobile/modules/t3-native-controls/ios/T3WorkspaceOrientationSubscriber.swift new file mode 100644 index 000000000000..8fa0a232d100 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3WorkspaceOrientationSubscriber.swift @@ -0,0 +1,22 @@ +import ExpoModulesCore +import UIKit + +/// Keep ordinary phones portrait-only while allowing a hinge-backed scene to rotate. +public final class T3WorkspaceOrientationSubscriber: ExpoAppDelegateSubscriber { + private static let hingeWindows = NSHashTable.weakObjects() + + static func allowHingeOrientations(in window: UIWindow) { + guard !hingeWindows.contains(window) else { return } + hingeWindows.add(window) + window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + + public func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + if UIDevice.current.userInterfaceIdiom == .pad { + // Keep iPad orientation policy, including portrait-only showcase captures. + let orientations = Bundle.main.object(forInfoDictionaryKey: "UISupportedInterfaceOrientations~ipad") as? [String] ?? [] + return orientations == ["UIInterfaceOrientationPortrait"] ? .portrait : .all + } + return window.map { Self.hingeWindows.contains($0) } == true ? .allButUpsideDown : .portrait + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 43933a7b3bf5..7556412b5a96 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -130,7 +130,7 @@ "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", "react-native-safe-area-context": "~5.9.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.17.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.5", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 195e4a1e77f3..b38131dfbe32 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -29,6 +29,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; +import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; import "../global.css"; @@ -95,12 +96,14 @@ function AppContent() { - - - - - - + + + + + + + + {/* Anchored-menu overlays render here — in-window, so the diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4f19d7a783c6..07fb3801c961 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -130,6 +130,7 @@ import { } from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; +import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -194,6 +195,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { presentation: "fullScreenModal", }; +// A navigator container leaves horizontal safe-area handling to its leaf screens. +const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false }; + const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { @@ -417,6 +421,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({ screens: { SettingsContent: createNativeStackScreen({ screen: SettingsContentStack, + options: NESTED_NAVIGATOR_OPTIONS, linking: "", layout: ({ children }) => ( @@ -853,6 +858,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: SettingsSheetStack, linking: "settings", options: { + ...NESTED_NAVIGATOR_OPTIONS, gestureEnabled: true, headerShown: false, }, @@ -919,6 +925,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ), options: { + ...NESTED_NAVIGATOR_OPTIONS, gestureEnabled: true, headerShown: false, }, @@ -959,8 +966,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { const { width, height } = useWindowDimensions(); + const nativeMetrics = useNativeLayoutMetrics(); const usesWorkspaceFlowScreens = - Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView; + Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView; return ( ): MailMenu["it } export function ScreenHeader(props: ScreenHeaderProps) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const headerId = useId(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const { themeVariables } = useAppearancePreferences(); + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const { search, menus } = props; const menu = menus?.[0]; const compactSearch = search !== undefined && (search.compactToolbar ?? !layout.usesSplitView) && - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + usesNativeMailSearchToolbar; const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); const refresh = search?.refreshInToolbar ? search.onRefresh : undefined; return ( @@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { : undefined), }), ] - : undefined, + : () => [], headerSearchBarOptions: compactSearch ? undefined : { @@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { onPress={props.backInSplitView.onPress ?? props.onBack} /> ) : null} - {props.sidebar !== false ? ( + {props.sidebar !== false && + (!usesNativeWorkspaceColumns || + (Platform.OS === "ios" && Platform.isPad) || + !panes.primarySidebarVisible) ? ( void) | null = null; + onmessage: ((event: { data: ArrayBuffer }) => void) | null = null; close = vi.fn(); send = vi.fn(); constructor() { @@ -86,6 +87,8 @@ it("bridges shared first-frame readiness, image failure, and a successful fresh const { elements, messages, sockets, configuration } = await setup(); sockets[0]!.onopen?.(); expect(messages()).not.toContainEqual({ type: "status", status: "streaming" }); + expect(messages()).not.toContainEqual({ type: "input", connected: true }); + sockets[0]!.onmessage?.({ data: new Uint8Array([0x83]).buffer }); expect(messages()).toContainEqual({ type: "input", connected: true }); const image = elements.find((element) => element.tag === "img")!; image.naturalWidth = 400; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index ac58bcc8c787..5dfe5ebc66a4 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,25 +1,43 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { use, useCallback, useRef } from "react"; +import { use, useCallback, useEffect, useRef } from "react"; +import { useNavigation, type ParamListBase } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { Platform } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; -import { - createNativeMailSearchToolbarItem, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, -} from "../layout/native-mail-search-toolbar"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; +import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; import type { HomeHeaderProps } from "./HomeHeader.types"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { + const navigation = useNavigation>(); + const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const primaryColumn = use(NativePrimaryColumnContext); - const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; + const sidebarHeader = + Platform.OS === "ios" && + primaryColumn !== null && + (Platform.isPad || !usesNativeMailSearchToolbar); const searchBarRef = useRef(null); + const focusAfterReveal = useRef(false); + useEffect( + () => + navigation.addListener("transitionEnd", (event) => { + if (focusAfterReveal.current && !event.data.closing) { + focusAfterReveal.current = false; + searchBarRef.current?.focus(); + } + }), + [navigation], + ); const iconColor = useUniwindTheme()["--color-icon"]; // The list uses a fixed creation order and ignores sort/group options, so // the filter menu only carries the filters and the "customized" icon state @@ -27,9 +45,14 @@ export function HomeHeader(props: HomeHeaderProps) { const hasCustomListOptions = props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const focusSearch = useCallback(() => { + if (primaryColumn && !panes.primarySidebarVisible) { + focusAfterReveal.current = true; + togglePrimarySidebar(); + return true; + } searchBarRef.current?.focus(); return searchBarRef.current !== null; - }, []); + }, [primaryColumn, panes.primarySidebarVisible, togglePrimarySidebar]); useHardwareKeyboardCommand("focusSearch", focusSearch); const filterMenu = buildHomeListFilterMenu(props); @@ -42,7 +65,7 @@ export function HomeHeader(props: HomeHeaderProps) { // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. headerTintColor: iconColor, unstable_headerRightItems: () => - iPadSidebar + sidebarHeader ? createSidebarHeaderItems({ filterIcon: hasCustomListOptions ? "line.3.horizontal.decrease.circle.fill" @@ -62,7 +85,7 @@ export function HomeHeader(props: HomeHeaderProps) { ], // The keys below are set per-branch (not `undefined`) so a later // reapply cannot clobber options owned by NativeHeaderToolbar. - ...(iPadSidebar + ...(sidebarHeader ? { headerSearchBarOptions: { ref: searchBarRef, @@ -78,7 +101,7 @@ export function HomeHeader(props: HomeHeaderProps) { }, unstable_headerToolbarItems: () => [], } - : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + : usesNativeMailSearchToolbar ? { headerSearchBarOptions: { ref: searchBarRef, @@ -121,7 +144,7 @@ export function HomeHeader(props: HomeHeaderProps) { }} /> - {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( + {sidebarHeader || usesNativeMailSearchToolbar ? null : ( (null); const insets = useSafeAreaInsets(); const { fabClearance } = useAndroidControlSizing(); + const screenMetrics = useNativeLayoutMetrics(); + const contentSideInsets = usesNativeWorkspaceColumns + ? (columnMetrics ?? screenMetrics)?.safeArea + : undefined; // UIKit's column safe area already includes its bottom toolbar. const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); const searchEnvironmentIds = useMemo( @@ -908,6 +917,8 @@ export function HomeScreen(props: HomeScreenProps) { style={{ paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, + paddingLeft: 32 + (contentSideInsets?.left ?? 0), + paddingRight: 32 + (contentSideInsets?.right ?? 0), }} > @@ -1038,7 +1049,8 @@ export function HomeScreen(props: HomeScreenProps) { {...scrollGateHandlers} scrollEventThrottle={16} contentContainerStyle={{ - paddingHorizontal: primaryColumn ? 8 : 0, + paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0), + paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0), paddingBottom: Platform.OS === "ios" ? iosBottomClearance diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 801d5e85da80..ee2574fcee04 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -66,6 +66,8 @@ import { NativeWorkspaceInspectorContext, } from "../../native/v5-workspace-context"; +import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; + interface AdaptiveWorkspaceContextValue { readonly layout: Layout; readonly panes: WorkspacePaneLayout; @@ -239,7 +241,9 @@ function AdaptiveWorkspaceLayoutContent( ) { const projectGroupingMode = props.projectGroupingMode; const nativeWorkspace = use(NativeWorkspaceModeContext); - const { width, height } = useWindowDimensions(); + const windowDimensions = useWindowDimensions(); + const nativeMetrics = useNativeLayoutMetrics(); + const { width, height } = nativeMetrics ?? windowDimensions; const pathname = props.pathname; const navigation = useNavigation(); const activeRoleOwner = useRef(null); @@ -256,7 +260,10 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = useState(null); - const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]); + const baseLayout = useMemo( + () => deriveLayout({ width, height, nativeMetrics }), + [height, width, nativeMetrics], + ); const layout = baseLayout; // In split layouts the sidebar IS the thread list — it renders on every // route, including Home (which shows an empty-detail pane instead of the @@ -269,7 +276,9 @@ function AdaptiveWorkspaceLayoutContent( viewportWidth: width, preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: - shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) : 0, + shouldRenderPrimarySidebar && showPrimarySidebar + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0, }), [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], ); @@ -470,13 +479,21 @@ function AdaptiveWorkspaceLayoutContent( ); const renderedSidebarWidth = useSharedValue( - panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, + panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, ); useEffect(() => { if (nativeWorkspace) return; - const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; + const targetWidth = panes.primarySidebarVisible + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0; renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); - }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); + }, [ + nativeWorkspace, + layout.listPaneWidth, + layout.listPaneGap, + panes.primarySidebarVisible, + renderedSidebarWidth, + ]); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index e642754a013b..7a3c9de54831 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -20,7 +20,7 @@ import { type NativeStackTypeBag, } from "@react-navigation/native-stack"; import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -33,8 +33,13 @@ import { V5CardStackView, V5StackView, } from "../../native/createV5StackNavigator.ios"; -import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns"; +import { + useNativeWorkspaceColumnsReady, + useNativeWorkspaceColumnsSupported, +} from "../../native/NativeWorkspaceColumns"; import { V5StackHeader } from "../../native/V5StackHeader.ios"; +import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; +import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { nativeWorkspacePopAction, projectWorkspaceStack, @@ -89,7 +94,12 @@ function ColumnScreen(props: { canGoBack={!props.primary} primary={props.primary} /> - + {descriptor.render()} @@ -107,6 +117,8 @@ function WorkspaceColumns( const inspector = use(NativeWorkspaceInspectorContext); const hostRef = useRef(null); const activeDetailKey = props.detail.at(-1)?.key; + const compactColumn = activeDetailKey ? "secondary" : "primary"; + const shownCompactColumn = useRef(compactColumn); const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; const selectedThreadKey = threadParams && @@ -131,9 +143,12 @@ function WorkspaceColumns( useEffect(() => { // In compact size classes UIKit exposes one column. Selecting a thread // changes the visible column without rebuilding either navigation stack. - if (layout.usesSplitView) return; - hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); - }, [activeDetailKey, layout.usesSplitView]); + // The initial column is a native prop, so it is applied before the host + // attaches. Commands only handle later navigation changes. + if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return; + shownCompactColumn.current = compactColumn; + hostRef.current?.show(compactColumn); + }, [compactColumn, layout.usesSplitView]); const primary = props.descriptors[props.primary.key]; if (!primary) return null; @@ -142,6 +157,7 @@ function WorkspaceColumns( ref={hostRef} testID="adaptive-workspace-layout" preferredSplitBehavior="tile" + topColumnForCollapsing={compactColumn} preferredDisplayMode={ panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly" } @@ -153,7 +169,7 @@ function WorkspaceColumns( }} columnMetrics={{ minimumPrimaryColumnWidth: 280, - maximumPrimaryColumnWidth: 380, + maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0), preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, minimumSecondaryColumnWidth: 320, }} @@ -179,6 +195,37 @@ function WorkspaceColumns( options={{ headerShown: true, title: "", + unstable_headerRightItems: () => + Platform.OS === "ios" && !Platform.isPad && layout.usesSplitView + ? [ + ...(!panes.primarySidebarVisible + ? [ + { + type: "button" as const, + axisBehavior: "verticalPreferred" as const, + accessibilityLabel: "Search threads", + label: "Search threads", + icon: { + type: "sfSymbol" as const, + name: "magnifyingglass" as const, + }, + onPress: () => { + dispatchHardwareKeyboardCommand("focusSearch"); + }, + }, + ] + : []), + { + type: "button", + axisBehavior: "verticalPreferred", + accessibilityLabel: "New task", + label: "New task", + icon: { type: "sfSymbol", name: "square.and.pencil" }, + onPress: () => + props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + }, + ] + : [], unstable_headerLeftItems: () => [ { type: "button", @@ -293,6 +340,8 @@ function WorkspaceStackNavigator({ UNSTABLE_router, ...rest }: NativeStackNavigatorProps) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); + const nativeWorkspaceColumnsReady = useNativeWorkspaceColumnsReady(); const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< StackNavigationState, StackRouterOptions, @@ -310,9 +359,10 @@ function WorkspaceStackNavigator({ screenLayout, UNSTABLE_router, }); - const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView; + if (!nativeWorkspaceColumnsReady) return null; + const WorkspaceView = usesNativeWorkspaceColumns ? WorkspaceStackView : V5StackView; return ( - + [ withNativeGlassHeaderItem({ + axisBehavior: "horizontalOnly", accessibilityLabel: "Filter settings environments and projects", icon: { name: filterIcon, type: "sfSymbol" }, label: "", @@ -120,6 +121,7 @@ export function SettingsEnvironmentFilterHeader(props: { ...(closeSettings ? [ withNativeGlassHeaderItem({ + axisBehavior: "horizontalOnly", accessibilityLabel: "Close settings", icon: { name: "xmark", type: "sfSymbol" }, identifier: "settings-close", diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 2f8fc3a33e7a..8330a6b1b54f 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -37,8 +37,8 @@ import { vcsEnvironment } from "../../state/vcs"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; +import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; @@ -413,7 +413,7 @@ export function BranchPickerScreen(props: { }) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const selectedBranchName = props.selectedBranchName ?? props.branches.find((branch) => branch.current)?.name ?? diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9bfa70bf4208..40c09b8bc64e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,6 +14,8 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; import { HeaderHeightContext } from "@react-navigation/elements"; +import { NativeLayoutObserver } from "../../native/NativeLayoutObserver"; +import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions"; import type { EnvironmentId, MessageId, @@ -84,6 +86,8 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; +import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; @@ -317,6 +321,7 @@ const USER_INPUT_TOGGLE_TIMING = { }; export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const navigation = useNavigation(); const { session: voiceInputSession } = useGlobalVoiceInput(); const reportedModelSelection = useThreadReportedModelSelection({ @@ -391,7 +396,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } }, []); const windowHeight = useWindowDimensions().height; - const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; + const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 44; + const [screenMetrics, setScreenMetrics] = useState(null); + const columnMetrics = useNativeColumnLayoutMetrics(); + const nativeMetrics = usesNativeWorkspaceColumns ? columnMetrics : screenMetrics; + const controlInsets = deriveBottomControlInsets(nativeMetrics); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; @@ -830,8 +839,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // Assign both layouts explicitly so the dock always follows its current parent. const composerWidthStyle = useAnimatedStyle(() => isSplitLayout && workspaceContentWidth !== null - ? { width: workspaceContentWidth.value } - : { width: "100%" }, + ? { + width: Math.max( + 0, + workspaceContentWidth.value - controlInsets.left - controlInsets.right, + ), + right: undefined, + } + : { width: undefined, right: controlInsets.right }, ); const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); @@ -1087,6 +1102,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return ( + {!usesNativeWorkspaceColumns ? : null} {showContent ? ( {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 44673dcf2759..a210ffcb1dc6 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -57,6 +57,7 @@ import { videoMimeType } from "@t3tools/shared/video"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; +import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { createContext, memo, @@ -297,6 +298,7 @@ export interface ThreadFeedProps { readonly contentBottomInset?: number; readonly historyControls?: ThreadFeedHistoryControls; readonly contentMaxWidth?: number; + readonly contentSideInsets?: { readonly left: number; readonly right: number }; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; @@ -1543,6 +1545,7 @@ function renderFeedEntry( readonly reviewCommentColors: ReviewCommentColors; readonly reviewCommentBubbleWidth: number; readonly themeAppearance: "light" | "dark"; + readonly usesNativeWorkspaceColumns: boolean; readonly userBubbleMaxWidth: number; /** Width assistant markdown lays out in, so images can size their frame before layout. */ readonly markdownContentWidth: number; @@ -1730,11 +1733,19 @@ function renderFeedEntry( className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5" style={{ backgroundColor: userBubbleColor, - maxWidth: props.userBubbleMaxWidth, + maxWidth: + props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad + ? "85%" + : props.userBubbleMaxWidth, ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : hasWideBlock - ? { width: props.userBubbleMaxWidth } + ? { + width: + props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad + ? "85%" + : props.userBubbleMaxWidth, + } : null), }} > @@ -2174,6 +2185,7 @@ function ThreadFeedPlaceholder(props: { } export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const navigation = useNavigation(); const { themeAppearance } = useAppearancePreferences(); const copyFeedbackTimeoutRef = useRef | null>(null); @@ -2257,12 +2269,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedFile(null); }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; + const contentLeftInset = props.contentSideInsets?.left ?? 0; + const contentRightInset = props.contentSideInsets?.right ?? 0; + const usableViewportWidth = Math.max(0, viewportWidth - contentLeftInset - contentRightInset); const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ - viewportWidth, + viewportWidth: usableViewportWidth, maxContentWidth: props.contentMaxWidth ?? null, minimumPadding: horizontalPadding, }); - const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); + const contentWidth = Math.max(0, usableViewportWidth - contentHorizontalPadding * 2); const userBubbleMaxWidth = contentWidth * 0.85; const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); @@ -2285,7 +2300,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // header-providing screen) and fall back to the standard iOS bar height. const navigationHeaderHeight = useContext(HeaderHeightContext); const anchorTopInset = usesNativeAutomaticInsets - ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT + ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT) : topContentInset; const theme = useUniwindTheme(); @@ -3030,6 +3045,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, + usesNativeWorkspaceColumns, userBubbleMaxWidth, markdownContentWidth, contentWidth, @@ -3067,6 +3083,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, + usesNativeWorkspaceColumns, userBubbleMaxWidth, markdownContentWidth, contentWidth, @@ -3119,7 +3136,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // (facebook/react-native#54123); the anchored end space after a send // is pure inset, so without this the blank region can't be scrolled. applyWorkaroundForContentInsetHitTestBug - contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"} + // Horizontal Duo reservations are already included in the row padding. + // Let UIKit adjust the scrolling axis without shifting content sideways. + contentInsetAdjustmentBehavior={ + usesNativeAutomaticInsets + ? usesNativeWorkspaceColumns && + Platform.OS === "ios" && + !Platform.isPad && + props.layoutVariant === "split" + ? "scrollableAxes" + : "automatic" + : "never" + } automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} {...(usesNativeAutomaticInsets ? { @@ -3231,7 +3259,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } contentContainerStyle={{ paddingTop: 12, - paddingHorizontal: contentHorizontalPadding, + paddingLeft: contentHorizontalPadding + contentLeftInset, + paddingRight: contentHorizontalPadding + contentRightInset, }} /> @@ -3239,7 +3268,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { !props.worktreeSetup && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( - + provider.instanceId === session.providerInstanceId) ?? null) : null; - const bottomToolbarInset = - Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED - ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET - : 0; + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); + const bottomToolbarInset = usesNativeMailSearchToolbar + ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + : 0; return ( @@ -1107,7 +1107,7 @@ function ThreadSettingsModelsScreen() { const session = useThreadSettingsSession(); const presentation = useThreadSettingsPickerPresentation(); const navigation = useNavigation>(); - const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; const commitAndClose = useCallback(() => { if (!session.commitPendingModel()) return; diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 1bcb225bf810..1741c527d91d 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -21,6 +21,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { NativeSheetContent } from "../../../native/NativeSheetContent"; import { useUniwindTheme } from "../../../lib/useUniwindTheme"; import { AndroidHeaderIconButton, @@ -411,7 +412,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} style={{ backgroundColor: sheetColor, flex: 1 }} > - {content} + {content} - {content} + {content} > = className: "text-adaptive-sky-600-400", iconTintClassName: "accent-adaptive-sky-600-400", }, + waiting: { + label: "Waiting", + icon: "clock", + className: "text-foreground-muted", + iconTintClassName: "accent-foreground-muted", + }, failed: { label: "Failed", icon: "exclamationmark.circle", diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx index a3d08e55cb2b..ce423ff96c0d 100644 --- a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx @@ -1,8 +1,11 @@ import { StackActions, useNavigation } from "@react-navigation/native"; import { useMemo } from "react"; +import { Platform } from "react-native"; import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; +import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; +import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { ThreadGitControls, useThreadGitCenterHeaderItems, @@ -19,18 +22,24 @@ export function useThreadHeaderOptions(props: { readonly gitControls: Parameters[0]; readonly onReturnToThread?: () => void; }) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); + const usesDuoHeader = usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad; const navigation = useNavigation(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); const splitLeftHeaderItems = useMemo( () => [ - { - // Match Mail's split-view detail toolbar: the first detail action sits - // inside the content pane, not flush against the sidebar divider. - spacing: 18, - type: "spacing" as const, - }, + ...(!usesDuoHeader + ? [ + { + // Match Mail's split-view detail toolbar: the first detail action sits + // inside the content pane, not flush against the sidebar divider. + spacing: 18, + type: "spacing" as const, + }, + ] + : []), ...(props.onReturnToThread ? [ withNativeGlassHeaderItem({ @@ -43,6 +52,7 @@ export function useThreadHeaderOptions(props: { ] : []), withNativeGlassHeaderItem({ + axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, accessibilityLabel: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar", @@ -51,18 +61,31 @@ export function useThreadHeaderOptions(props: { type: "sfSymbol" as const, }, identifier: "thread-left-sidebar", + label: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar", onPress: togglePrimarySidebar, type: "button" as const, }), - withNativeGlassHeaderItem({ - accessibilityLabel: "New task", - icon: { name: "square.and.pencil", type: "sfSymbol" as const }, - identifier: "thread-left-new-task", - onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), - type: "button" as const, - }), + ...(!usesDuoHeader + ? [ + withNativeGlassHeaderItem({ + accessibilityLabel: "New task", + icon: { name: "square.and.pencil", type: "sfSymbol" as const }, + identifier: "thread-left-new-task", + label: "New task", + onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + type: "button" as const, + }), + ] + : []), + ], + [ + panes.primarySidebarVisible, + props.onReturnToThread, + navigation, + togglePrimarySidebar, + usesDuoHeader, + usesNativeWorkspaceColumns, ], - [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], ); // Deep links / cold starts land with Thread as the ONLY route, where the // native back button does not render. Provide an explicit Home escape for @@ -81,6 +104,41 @@ export function useThreadHeaderOptions(props: { [navigation], ); + const duoRightHeaderItems = useMemo( + () => [ + ...threadCenterHeaderItems, + { type: "spacing", spacing: 8 }, + ...(layout.usesSplitView && + Platform.OS === "ios" && + !Platform.isPad && + !panes.primarySidebarVisible + ? [ + withNativeGlassHeaderItem({ + type: "button" as const, + axisBehavior: "verticalPreferred", + identifier: "thread-right-search", + label: "Search threads", + accessibilityLabel: "Search threads", + icon: { name: "magnifyingglass", type: "sfSymbol" as const }, + onPress: () => { + dispatchHardwareKeyboardCommand("focusSearch"); + }, + }), + ] + : []), + withNativeGlassHeaderItem({ + type: "button" as const, + axisBehavior: "verticalPreferred", + identifier: "thread-right-new-task", + label: "New task", + accessibilityLabel: "New task", + icon: { name: "square.and.pencil", type: "sfSymbol" as const }, + onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + }), + ], + [navigation, threadCenterHeaderItems, layout.usesSplitView, panes.primarySidebarVisible], + ); + const options: AppNativeStackNavigationOptions = { headerShown: true, headerTitle: props.title, @@ -104,7 +162,12 @@ export function useThreadHeaderOptions(props: { // the git controls on the RIGHT (no center items — center space is // reserved for future breadcrumbs/status). unstable_headerRightItems: () => - layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, + usesDuoHeader + ? duoRightHeaderItems + : layout.usesSplitView + ? threadCenterHeaderItems + : compactRightHeaderItems, + unstable_headerToolbarItems: () => [], unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, contentStyle: undefined, }; @@ -116,7 +179,11 @@ export function useThreadHeaderOptions(props: { // callbacks also read state the items do not display (a "Push" item runs `push` or // `commit_push` depending on the default ref), so that state is keyed too. optionsVersion: [ - layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, + splitLeftHeaderItems, + threadCenterHeaderItems, + compactRightHeaderItems, + duoRightHeaderItems, + usesDuoHeader, environmentId, threadId, gitStatus?.isDefaultRef, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ee38ac020e74..ac4cde7789b0 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -1,5 +1,6 @@ import { scaledTypographyLineHeight } from "./appearancePreferences"; import { MOBILE_TYPOGRAPHY } from "./typography"; +import type { NativeLayoutMetrics } from "./reserved-regions"; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); @@ -56,6 +57,7 @@ export interface Layout { readonly variant: LayoutVariant; readonly usesSplitView: boolean; readonly listPaneWidth: number | null; + readonly listPaneGap?: number; readonly shellPadding: number; } @@ -87,9 +89,25 @@ export function deriveThreadFeedInitialContentInset(input: { export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; -export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { +export function deriveLayout(input: { + readonly width: number; + readonly height: number; + readonly nativeMetrics?: NativeLayoutMetrics | null; +}): Layout { const { width, height } = input; - const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; + const metrics = input.nativeMetrics; + const usableWidth = width - (metrics?.safeArea.left ?? 0) - (metrics?.safeArea.right ?? 0); + // UIKit's size class supports the shorter Duo inner display. Geometry remains + // a floor so a narrow multitasking window cannot squeeze both columns. + const hasDuoGeometry = + metrics && + (metrics.verticalBarEdge !== "none" || + metrics.reservedRegions.some((region) => region.kind === "division")); + const wideEnoughForSplit = hasDuoGeometry + ? metrics.horizontalSizeClass === "regular" && + usableWidth >= SPLIT_SIDEBAR_MIN_WIDTH + 320 && + height >= 400 + : width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; if (!wideEnoughForSplit) { return { @@ -100,14 +118,20 @@ export function deriveLayout(input: { readonly width: number; readonly height: n }; } + const division = metrics?.reservedRegions.find( + (region) => + region.kind === "division" && + region.height >= height / 2 && + region.x - metrics.safeArea.left >= SPLIT_SIDEBAR_MIN_WIDTH && + width - region.x - region.width - metrics.safeArea.right >= 320, + ); return { variant: "split", usesSplitView: true, - listPaneWidth: clamp( - Math.round(width * 0.32), - SPLIT_SIDEBAR_MIN_WIDTH, - SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH, - ), + listPaneWidth: + division?.x ?? + clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH), + ...(division ? { listPaneGap: division.width } : {}), shellPadding: 0, }; } @@ -125,7 +149,7 @@ export function deriveWorkspacePaneLayout(input: { const preferredPrimarySidebarVisible = input.layout.usesSplitView && input.primarySidebarPreferredVisible; const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible - ? (input.layout.listPaneWidth ?? 0) + ? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0) : 0; if (auxiliaryPaneRole === "inspector") { @@ -141,11 +165,14 @@ export function deriveWorkspacePaneLayout(input: { auxiliaryPaneVisible && fileInspector.width !== null && input.layout.listPaneWidth !== null && - viewportWidth - input.layout.listPaneWidth - fileInspector.width < + viewportWidth - + input.layout.listPaneWidth - + (input.layout.listPaneGap ?? 0) - + fileInspector.width < FILE_INSPECTOR_MIN_MAIN_WIDTH; const primarySidebarVisible = preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; - const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; + const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0; return { primarySidebarVisible, diff --git a/apps/mobile/src/lib/reserved-regions.test.ts b/apps/mobile/src/lib/reserved-regions.test.ts new file mode 100644 index 000000000000..e742169bd94a --- /dev/null +++ b/apps/mobile/src/lib/reserved-regions.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vite-plus/test"; +import { deriveBottomControlInsets, type NativeLayoutMetrics } from "./reserved-regions"; +import { deriveLayout, deriveWorkspacePaneLayout } from "./layout"; + +const innerDisplay: NativeLayoutMetrics = { + width: 960, + height: 560, + horizontalSizeClass: "regular", + verticalBarEdge: "right", + safeArea: { top: 0, bottom: 20, left: 8, right: 72 }, + reservedRegions: [], +}; + +describe("bottom controls around reserved regions", () => { + it("respects asymmetric side controls without reserving the bottom twice", () => { + expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 }); + }); + + it("leaves ordinary non-native clients unchanged", () => { + expect(deriveBottomControlInsets(null)).toEqual({ left: 0, right: 0, bottom: 0 }); + }); + + it("keeps the editor entirely on one side of a book fold", () => { + const insets = deriveBottomControlInsets({ + ...innerDisplay, + reservedRegions: [{ kind: "division", x: 456, y: 0, width: 48, height: 560 }], + }); + expect(insets).toEqual({ left: 8, right: 504, bottom: 0 }); + expect(innerDisplay.width - insets.left - insets.right).toBeGreaterThan(320); + }); + + it("keeps controls below a horizontal fold in a hands-free pose", () => { + expect( + deriveBottomControlInsets({ + ...innerDisplay, + reservedRegions: [{ kind: "division", x: 0, y: 240, width: 960, height: 40 }], + }), + ).toEqual({ left: 8, right: 72, bottom: 0 }); + }); + + it("moves a control away from a bottom camera while leaving a top camera alone", () => { + expect( + deriveBottomControlInsets({ + ...innerDisplay, + reservedRegions: [ + { kind: "occlusion", x: 8, y: 0, width: 60, height: 60 }, + { kind: "occlusion", x: 8, y: 510, width: 60, height: 50 }, + ], + }), + ).toEqual({ left: 68, right: 72, bottom: 0 }); + }); + + it("restores the full editor width when the device opens flat", () => { + const folded = { + ...innerDisplay, + reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }], + }; + expect(deriveBottomControlInsets(folded)).not.toEqual(deriveBottomControlInsets(innerDisplay)); + expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 }); + }); +}); + +describe("Duo workspace sizing", () => { + it("shows both columns on a short regular-width inner display", () => { + const layout = deriveLayout({ ...innerDisplay, nativeMetrics: innerDisplay }); + expect(layout.usesSplitView).toBe(true); + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: innerDisplay.width, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + }).contentPaneWidth, + ).toBeGreaterThan(320); + }); + + it("keeps an ordinary phone compact when it rotates", () => { + expect(deriveLayout({ width: 852, height: 393 }).usesSplitView).toBe(false); + for (const [width, height] of [ + [932, 430], + [650, 500], + ]) { + const metrics: NativeLayoutMetrics = { + ...innerDisplay, + width: width!, + height: height!, + verticalBarEdge: "none", + safeArea: { top: 0, bottom: 20, left: 59, right: 59 }, + }; + expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); + const cameraMetrics = { + ...metrics, + reservedRegions: [{ kind: "occlusion" as const, x: 0, y: 0, width: 60, height: 60 }], + }; + expect(deriveLayout({ ...cameraMetrics, nativeMetrics: cameraMetrics }).usesSplitView).toBe( + false, + ); + } + expect( + deriveLayout({ + width: 960, + height: 560, + nativeMetrics: { + ...innerDisplay, + horizontalSizeClass: "compact", + }, + }).usesSplitView, + ).toBe(false); + }); + + it("requires enough safe width for both panes in a multitasking window", () => { + const metrics = { ...innerDisplay, width: 660 }; + expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); + }); + + it("aligns the panes to the book fold and accounts for the reserved gap", () => { + const metrics = { + ...innerDisplay, + reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }], + }; + const layout = deriveLayout({ ...metrics, nativeMetrics: metrics }); + expect(layout.listPaneWidth).toBe(456); + expect(layout.listPaneGap).toBe(48); + const panes = deriveWorkspacePaneLayout({ + layout, + viewportWidth: 960, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + }); + expect(panes.contentPaneWidth).toBe(456); + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 960, + primarySidebarPreferredVisible: false, + auxiliaryPanePreferredVisible: false, + }).contentPaneWidth, + ).toBe(960); + }); +}); diff --git a/apps/mobile/src/lib/reserved-regions.ts b/apps/mobile/src/lib/reserved-regions.ts index 318896239536..38496931f9de 100644 --- a/apps/mobile/src/lib/reserved-regions.ts +++ b/apps/mobile/src/lib/reserved-regions.ts @@ -1,12 +1,51 @@ -/** Geometry of the body inside a native navigation column. */ +export interface ReservedRegion { + readonly kind: "division" | "occlusion"; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + export interface NativeLayoutMetrics { readonly width: number; readonly height: number; + readonly hasHinge?: boolean; readonly horizontalSizeClass: "compact" | "regular"; + readonly verticalBarEdge: "none" | "left" | "right"; readonly safeArea: { readonly top: number; readonly bottom: number; readonly left: number; readonly right: number; }; + readonly reservedRegions: ReadonlyArray; +} + +/** + * Keep bottom controls in one usable region. A book-like fold selects the + * larger side; a horizontal fold leaves the controls on the lower display. + * Text and other scrollable content can continue to use the full canvas. + */ +export function deriveBottomControlInsets(metrics: NativeLayoutMetrics | null) { + if (!metrics) return { left: 0, right: 0, bottom: 0 }; + let left = metrics.safeArea.left; + let right = metrics.width - metrics.safeArea.right; + let bottom = metrics.height; + for (const region of metrics.reservedRegions) { + const x = Math.max(left, region.x); + const endX = Math.min(right, region.x + region.width); + const y = Math.max(0, region.y); + const endY = Math.min(bottom, region.y + region.height); + if (endX <= x || endY <= y) continue; + if (region.kind === "division" && region.height >= metrics.height / 2) { + if (right - endX >= x - left) left = endX; + else right = x; + } else if (endY >= bottom - metrics.safeArea.bottom) { + // An occlusion at the bottom can also occupy just one corner. + if (region.width >= right - left) bottom = y; + else if (right - endX >= x - left) left = endX; + else right = x; + } + } + return { left, right: metrics.width - right, bottom: metrics.height - bottom }; } diff --git a/apps/mobile/src/native/NativeColumnContent.ios.tsx b/apps/mobile/src/native/NativeColumnContent.ios.tsx index 77b03098b36c..8a8fa0f599c8 100644 --- a/apps/mobile/src/native/NativeColumnContent.ios.tsx +++ b/apps/mobile/src/native/NativeColumnContent.ios.tsx @@ -9,12 +9,16 @@ import { NativeLayoutObserver } from "./NativeLayoutObserver"; export function NativeColumnContent(props: { readonly children: ReactNode; readonly primary?: boolean; + readonly insetHorizontally?: boolean; }) { const [metrics, setMetrics] = useState(null); return ( - + {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx index 5bf5aa27c91d..71a18fa5c2c6 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx @@ -14,6 +14,7 @@ import { type ReactNode, } from "react"; import type { ColorValue } from "react-native"; +import type { HeaderBarButtonSearchBarPlacementItem } from "react-native-screens"; function useNativeStackNavigation(): NativeStackNavigationProp | null { return useNavigation>(); @@ -34,8 +35,13 @@ function labelFromChildren(children: ReactNode): string { type NativeStackHeaderIcon = NonNullable< Extract["icon"] >; +type NativeStackHeaderAxisBehavior = Extract< + NativeStackHeaderItem, + { type: "button" } +>["axisBehavior"]; +type NativeToolbarItem = NativeStackHeaderItem | HeaderBarButtonSearchBarPlacementItem; type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { - unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; + unstable_headerToolbarItems?: () => NativeToolbarItem[]; }; function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { @@ -112,7 +118,7 @@ function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu" return items; } -function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { +function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { if (!isValidElement(child)) { return null; } @@ -121,15 +127,22 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) { return { type: "custom", element: child.props.children }; } + if (typeName === "NativeHeaderToolbarSearchBarSlot") { + return { type: "searchBarPlacement" }; + } if (typeName === "NativeHeaderToolbarButton") { return { type: "button", - label: typeof child.props.label === "string" ? child.props.label : "", + label: + typeof child.props.label === "string" + ? child.props.label + : String(child.props.accessibilityLabel ?? ""), accessibilityLabel: typeof child.props.accessibilityLabel === "string" ? child.props.accessibilityLabel : undefined, + axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior, disabled: Boolean(child.props.disabled), icon: iconFromProp(child.props.icon), onPress: @@ -173,8 +186,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { return null; } -function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { - const items: NativeStackHeaderItem[] = []; +function collectToolbarItems(children: ReactNode): NativeToolbarItem[] { + const items: NativeToolbarItem[] = []; Children.forEach(children, (child) => { const item = convertToolbarChild(child); if (item) { @@ -211,13 +224,17 @@ function NativeHeaderToolbarRoot(props: { } as NativeStackOptionsWithToolbar); }; } + // UIKit's search placement item belongs to the toolbar, never a header group. + const headerItems = items.filter( + (item): item is NativeStackHeaderItem => item.type !== "searchBarPlacement", + ); if (props.placement === "left") { - navigation.setOptions({ unstable_headerLeftItems: () => items }); + navigation.setOptions({ unstable_headerLeftItems: () => headerItems }); return () => { navigation.setOptions({ unstable_headerLeftItems: () => [] }); }; } - navigation.setOptions({ unstable_headerRightItems: () => items }); + navigation.setOptions({ unstable_headerRightItems: () => headerItems }); return () => { navigation.setOptions({ unstable_headerRightItems: () => [] }); }; @@ -228,6 +245,7 @@ function NativeHeaderToolbarRoot(props: { function NativeHeaderToolbarButton(_props: { readonly accessibilityLabel?: string; + readonly axisBehavior?: NativeStackHeaderAxisBehavior; readonly disabled?: boolean; readonly icon?: string; readonly label?: string; diff --git a/apps/mobile/src/native/NativeSheetContent.tsx b/apps/mobile/src/native/NativeSheetContent.tsx new file mode 100644 index 000000000000..7b508f3bc944 --- /dev/null +++ b/apps/mobile/src/native/NativeSheetContent.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; + +import { useNativeWorkspaceColumnsSupported } from "./NativeWorkspaceColumns"; + +/** Reserve the sheet's own side bars without moving its native navigation chrome. */ +export function NativeSheetContent(props: { readonly children: ReactNode }) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); + if (!usesNativeWorkspaceColumns) return <>{props.children}; + + // A floating sheet can have different insets from the workspace underneath it. + return ( + + + {props.children} + + + ); +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx index c85529b1dbf2..9aee432ed28d 100644 --- a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx +++ b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx @@ -1,7 +1,29 @@ import { requireOptionalNativeModule } from "expo"; import { Platform } from "react-native"; -const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( - "T3NativeControls", -); +import { useNativeLayoutMetrics } from "./native-layout-metrics"; + +const nativeControls = requireOptionalNativeModule<{ + readonly supportsWorkspaceColumns?: boolean; + readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; +}>("T3NativeControls"); export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; + +export function useNativeWorkspaceColumnsReady() { + const metrics = useNativeLayoutMetrics(); + // Choose the phone's native host before attaching any screens. Older clients + // without the observer can immediately use the compact navigator. + return ( + (Platform.OS === "ios" && Platform.isPad) || + !nativeControls?.ViewPrototypes?.T3NativeControls_LayoutMetrics || + metrics !== null + ); +} + +export function useNativeWorkspaceColumnsSupported() { + const metrics = useNativeLayoutMetrics(); + return ( + NATIVE_WORKSPACE_COLUMNS_SUPPORTED || + (nativeControls?.supportsWorkspaceColumns === true && metrics?.hasHinge === true) + ); +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.tsx index 4bb7aa16ac9b..d8fd7b664c17 100644 --- a/apps/mobile/src/native/NativeWorkspaceColumns.tsx +++ b/apps/mobile/src/native/NativeWorkspaceColumns.tsx @@ -1,3 +1,11 @@ import { Platform } from "react-native"; // Android retains its existing navigator. The iOS file checks native availability. export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; + +export function useNativeWorkspaceColumnsReady() { + return true; +} + +export function useNativeWorkspaceColumnsSupported() { + return NATIVE_WORKSPACE_COLUMNS_SUPPORTED; +} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 4f8eadcc5a11..6857e244f71f 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -19,6 +19,8 @@ export type AppNativeStackNavigationOptions = Omit< NativeStackNavigationOptions, "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems" > & { + /** Nested navigator leaves apply their own horizontal safe area. */ + readonly nativeContentInsetHorizontally?: boolean; readonly headerTintColor?: string | ColorValue; readonly unstable_headerCenterItems?: unknown; readonly unstable_headerLeftItems?: unknown; diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx index 2c843dab7cdc..e37647f4e76d 100644 --- a/apps/mobile/src/native/V5StackHeader.ios.tsx +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -6,6 +6,7 @@ import type { import { isValidElement, useRef } from "react"; import { Platform, StyleSheet } from "react-native"; import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; +import { useNativeLayoutMetrics } from "../native/native-layout-metrics"; import { SearchBar, Stack, @@ -110,6 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte id, identifier: item.identifier, title: item.label || item.accessibilityLabel || undefined, + axisBehavior: item.axisBehavior, icon: convertIcon(item.icon), menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), hidesSharedBackground: item.hidesSharedBackground, @@ -120,6 +122,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte id, identifier: item.identifier, title: item.label || item.accessibilityLabel || undefined, + axisBehavior: item.axisBehavior, icon: convertIcon(item.icon), onPress: item.disabled ? undefined : item.onPress, hidesSharedBackground: item.hidesSharedBackground, @@ -136,6 +139,7 @@ export function V5StackHeader(props: { }) { const { options } = props; const theme = useMobileNavigationTheme(); + const nativeMetrics = useNativeLayoutMetrics(); const searchRef = useRef(null); const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); @@ -162,6 +166,7 @@ export function V5StackHeader(props: { bottom.push({ type: "item", id: mailSearch.filterButtonId ?? "filter", + axisBehavior: bottomSearch ? "horizontalOnly" : undefined, icon: { type: "sfSymbol", name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", @@ -171,7 +176,12 @@ export function V5StackHeader(props: { if (bottomSearch) { if (bottom.length) bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); - bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); + bottom.push({ + type: "item", + id: "home-search", + axisBehavior: "horizontalOnly", + searchBarPlacement: true, + }); if (mailSearch.onComposePress) bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); } @@ -179,10 +189,22 @@ export function V5StackHeader(props: { bottom.push({ type: "item", id: mailSearch.composeButtonId ?? "compose", + axisBehavior: bottomSearch ? "horizontalOnly" : undefined, icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, onPress: mailSearch.onComposePress, }); } + // Expanded Duo panes put toolbar actions in the side bar. Folded phones + // keep their bottom search toolbar. UIKit keeps the + // primary column's navigation items beside its title and the detail's in + // the rail; a conventional UIToolbar can otherwise leave them inaccessible. + if ( + nativeMetrics?.horizontalSizeClass === "regular" && + nativeMetrics.verticalBarEdge !== "none" + ) { + trailing.push(...bottom); + bottom.length = 0; + } const left = options.headerLeft?.(itemProps); const right = options.headerRight?.(itemProps); if (!options.unstable_headerLeftItems && isValidElement(left)) diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx index 47edb3c1e816..9c8cc25f4102 100644 --- a/apps/mobile/src/native/createV5StackNavigator.ios.tsx +++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx @@ -24,6 +24,7 @@ import { useCallback, useRef, useState, type ComponentProps } from "react"; import { StyleSheet, View } from "react-native"; import { FormSheet, Stack } from "react-native-screens"; import { V5StackHeader } from "./V5StackHeader.ios"; +import type { AppNativeStackNavigationOptions } from "./StackHeader"; import { NativeColumnContent } from "./NativeColumnContent.ios"; import { nativeStackPopAction, @@ -232,7 +233,14 @@ export function V5CardStackView(props: V5StackViewProps) { : index > 0 } /> - {descriptor.render()} + + {descriptor.render()} + diff --git a/apps/mobile/src/native/use-native-mail-search-toolbar.ts b/apps/mobile/src/native/use-native-mail-search-toolbar.ts new file mode 100644 index 000000000000..b7791ff926a9 --- /dev/null +++ b/apps/mobile/src/native/use-native-mail-search-toolbar.ts @@ -0,0 +1,13 @@ +import { useNativeLayoutMetrics } from "./native-layout-metrics"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native-glass"; + +/** Folded phones keep search in the bottom toolbar; expanded Duo panes use the side bar. */ +export function useNativeMailSearchToolbar() { + const metrics = useNativeLayoutMetrics(); + return ( + NATIVE_LIQUID_GLASS_SUPPORTED && + (metrics === null || + metrics.horizontalSizeClass === "compact" || + metrics.verticalBarEdge === "none") + ); +} diff --git a/apps/server/integration/SqlStatementCounter.integration.ts b/apps/server/integration/SqlStatementCounter.integration.ts index e3a3aac3fba0..c75fd5d9b73f 100644 --- a/apps/server/integration/SqlStatementCounter.integration.ts +++ b/apps/server/integration/SqlStatementCounter.integration.ts @@ -7,16 +7,17 @@ export interface SqlStatementCounter { } /** - * Counts `sql.execute` spans, which the Effect SQL client opens once per - * statement. Spans behave exactly as with the native tracer. Install the same - * counter on every runtime under test, otherwise statements run by background - * reactors and statements run by request handlers land in different counters. + * Counts the client spans the Effect SQL client opens once per statement. Our + * SQLite client names them after its `db.system.name`, `sqlite`. Spans behave + * exactly as with the native tracer. Install the same counter on every runtime + * under test, otherwise statements run by background reactors and statements + * run by request handlers land in different counters. */ export function makeSqlStatementCounter(): SqlStatementCounter { let statements = 0; const tracer = Tracer.make({ span: (options) => { - if (options.name === "sql.execute") statements += 1; + if (options.kind === "client" && options.name === "sqlite") statements += 1; return new Tracer.NativeSpan(options); }, }); diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts index 53f46d576d2d..de513dbe0f0c 100644 --- a/apps/server/integration/TransferBudgetReport.integration.ts +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -30,9 +30,9 @@ export interface TransferBudgetRun { /** The second client resubscribes after the turn from the cursor it held before it. */ readonly reconnectThread: WebSocketCatchUpMeasurement; readonly reconnectShell: WebSocketCatchUpMeasurement; - /** `sql.execute` spans opened server-wide during the measured turn. */ + /** SQL statement spans opened server-wide during the measured turn. */ readonly measuredTurnSqlStatements: number; - /** `sql.execute` spans opened while serving both reconnect catch-ups. */ + /** SQL statement spans opened while serving both reconnect catch-ups. */ readonly reconnectSqlStatements: number; } @@ -200,7 +200,7 @@ export function formatTransferBudgetReport(runs: ReadonlyArray !/^\d+$/.test(number))) { @@ -41,6 +46,17 @@ function withRateLimit(query: string): string { : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`; } +// The server's host port over default settings: gh's own account choice, no saved token. +const sourceControlHost = ServerSourceControlHost.layer.pipe( + Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), + Layer.provide(GitVcsDriver.layer), + Layer.provide(ServerSettings.layerTest()), + // A scratch state directory, so nothing here touches a real T3 home. + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-measure-pr-preview-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + const measuredApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { @@ -73,18 +89,15 @@ const measuredApi = Layer.effect( }); }), ).pipe( - Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), + Layer.provide(GitHubCredentials.layer), Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.provide(FetchHttpClient.layer), - Layer.provide(VcsProcess.layer), - Layer.provide(NodeServices.layer), ); const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(measuredApi), - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(sourceControlHost), ); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts index 4343aec87ba1..0d18e917e031 100644 --- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts +++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts @@ -5,7 +5,7 @@ import { revertCodexThread } from "../src/provider/CodexThreadRevert.ts"; import { buildCodexInitializeParams } from "../src/provider/CodexProvider.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; import * as Deferred from "effect/Deferred"; @@ -1211,7 +1211,8 @@ function layerCodex({ recorder }: { readonly recorder: Recorder }) { Effect.gen(function* () { // The vp node shim marks its children with VP_TOOL_RECURSION, which makes every // `node` the agent runs through a shell fail. Codex must not inherit it. - const { VP_TOOL_RECURSION: _vpToolRecursion, ...environment } = yield* HostProcessEnvironment; + const { VP_TOOL_RECURSION: _vpToolRecursion, ...environment } = + yield* HostProcess.Environment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const commandName = environment.T3_CODEX_BIN ?? environment.CODEX_BIN ?? "codex"; const spawnCommand = yield* resolveSpawnCommand(commandName, ["app-server"], { diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index f04b04cfe348..6415a145690d 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -12,7 +12,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { type ProviderReplayEntry } from "@t3tools/contracts"; import { GrokSettings } from "@t3tools/provider-grok/settings"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; @@ -442,12 +442,12 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s const settings = { ...DEFAULT_GROK_SETTINGS, binaryPath: process.env.T3_GROK_BIN ?? "grok" }; const layerRegistry = ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { - const environment = yield* HostProcessEnvironment; + const environment = yield* HostProcess.Environment; const adapter = yield* makeGrokAdapterV2({ instanceId: GROK_DEFAULT_INSTANCE_ID, settings, environment, - hostPlatform: yield* HostProcessPlatform, + hostPlatform: yield* HostProcess.Platform, selfInvocation: yield* resolveSelfInvocation(), continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, // Production's runtime factory, with the protocol logger teeing raw lines. diff --git a/apps/server/scripts/t3-sqlite-state.test.ts b/apps/server/scripts/t3-sqlite-state.test.ts index 45bc78b30e0d..4946d1985639 100644 --- a/apps/server/scripts/t3-sqlite-state.test.ts +++ b/apps/server/scripts/t3-sqlite-state.test.ts @@ -7,7 +7,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runSqliteState } from "./t3-sqlite-state.ts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* ( @@ -90,7 +90,7 @@ it.layer(NodeServices.layer)("t3-sqlite-state", (it) => { sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')", }); assert.equal(mutation.operation, "exec"); - if (mutation.operation === "exec" && (yield* HostProcessPlatform) !== "win32") { + if (mutation.operation === "exec" && (yield* HostProcess.Platform) !== "win32") { // NTFS has no POSIX mode bits to report. assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600); } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 12ec00256973..8a92b40aaea2 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; import { AssetAccessError, AssetPreviewTypeValidationError, @@ -35,19 +34,15 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile } from "./MediaFile.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import { githubMediaResponse } from "./GitHubMediaFetch.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; }); -vi.mock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, homedir: vi.fn(actual.homedir) }; -}); - const layerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); @@ -299,8 +294,7 @@ describe("AssetAccess", () => { yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, "recording bytes"); const canonicalFile = yield* fs.realPath(filePath); - const homeSpy = vi.mocked(NodeOS.homedir).mockReturnValue(home); - try { + yield* Effect.gen(function* () { for (const workspaceRoot of [ path.join(home, "project"), path.join(directory, "srv", "project"), @@ -332,9 +326,7 @@ describe("AssetAccess", () => { expect(yield* Effect.promise(() => response.text())).toBe("recording bytes"); } } - } finally { - homeSpy.mockRestore(); - } + }).pipe(Effect.provideService(HostProcess.HomeDirectory, home)); }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c9fbc064168c..43d7d75f7864 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -58,6 +58,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -469,7 +470,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "media-file": { - let requestedPath = expandHomePath(input.resource.path); + let requestedPath = expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory); if (!path.isAbsolute(requestedPath)) { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts index 7b26c3e69acd..d05308f2afed 100644 --- a/apps/server/src/assets/GitHubMediaFetch.ts +++ b/apps/server/src/assets/GitHubMediaFetch.ts @@ -12,7 +12,7 @@ import { type HttpClientResponse, } from "effect/http"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; /** * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned diff --git a/apps/server/src/assets/NativeAppIconResolver.test.ts b/apps/server/src/assets/NativeAppIconResolver.test.ts index 753f7378285d..6c69eebceafc 100644 --- a/apps/server/src/assets/NativeAppIconResolver.test.ts +++ b/apps/server/src/assets/NativeAppIconResolver.test.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; @@ -44,7 +44,7 @@ describe("resolveNativeAppIcon", () => { }); const layerDependencies = Layer.mergeAll( layerConfig, - Layer.succeed(HostProcessPlatform, "darwin"), + Layer.succeed(HostProcess.Platform, "darwin"), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ).pipe(Layer.provideMerge(NodeServices.layer)); const layerTest = NativeAppIconResolver.layer.pipe(Layer.provide(layerDependencies)); diff --git a/apps/server/src/assets/NativeAppIconResolver.ts b/apps/server/src/assets/NativeAppIconResolver.ts index df5c8a03f670..e25610e5223a 100644 --- a/apps/server/src/assets/NativeAppIconResolver.ts +++ b/apps/server/src/assets/NativeAppIconResolver.ts @@ -15,7 +15,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Semaphore from "effect/Semaphore"; import * as Hex from "effect/encoding/Hex"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; @@ -214,7 +214,7 @@ const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUnc /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const hostPlatform = yield* HostProcessPlatform; + const hostPlatform = yield* HostProcess.Platform; const resolutionSemaphore = yield* Semaphore.make(2); const resolutionCache: Cache.Cache< string, diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index d532f71c9fb8..0b14c7dd1e23 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { BootstrapEnvelopeDecodeError, @@ -55,7 +55,7 @@ vi.mock("node:fs", async (importOriginal) => { }; }); -const windowsHost = HostProcessPlatform.defaultValue() === "win32"; +const windowsHost = HostProcess.Platform.defaultValue() === "win32"; const nullDevice = windowsHost ? "\\\\.\\NUL" : "/dev/null"; const closeIfOpen = (fd: number) => { try { @@ -116,7 +116,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { try { const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100, - }).pipe(Effect.provideService(HostProcessPlatform, "linux")); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")); assertSome(payload, { mode: "desktop", }); @@ -141,7 +141,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { try { const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100, - }).pipe(Effect.provideService(HostProcessPlatform, "linux"), Effect.flip); + }).pipe(Effect.provideService(HostProcess.Platform, "linux"), Effect.flip); assert.instanceOf(error, BootstrapInputStreamOpenError); assert.equal(error.fd, fd); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 71dae0dc3096..708a8fd9119b 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -10,7 +10,7 @@ import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; export class BootstrapFdStatError extends Schema.TaggedError()( "BootstrapFdStatError", @@ -168,7 +168,7 @@ const isFdReady = (fd: number) => const makeBootstrapInputStream = (fd: number) => Effect.gen(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const fdPath = resolveFdPath(fd, platform); return yield* Effect.try({ try: () => { diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index c1ad293d40ab..2846b86e1b88 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -4,7 +4,7 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { ThreadId, type VcsError } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -329,7 +329,7 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { const copiedText = Array.from({ length: 20 }, (_, index) => `copy line ${index}\n`).join( "", ); - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const renamedPath = platform === "win32" ? "renamed café.txt" : "renamed\tcafé\nname.txt"; const addedPath = platform === "win32" ? "new café.txt" : "new\tfile\n名.txt"; for (const [path, contents] of Object.entries({ diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 4bde751dcf41..e785baa58f1c 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -8,29 +8,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import type { DesktopAppActivationRequest } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; -import { - HostProcessPlatform, - HostProcessUserId, - HostProcessWorkingDirectory, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { Command } from "effect/cli"; -import { afterEach, describe, expect, vi } from "vite-plus/test"; +import { describe, expect } from "vite-plus/test"; import { makeCli } from "../binCli.ts"; import { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; -vi.mock("node:os", async (importOriginal) => { - const os = await importOriginal(); - return { ...os, homedir: vi.fn(os.homedir) }; -}); - -afterEach(() => vi.mocked(NodeOS.homedir).mockReset()); - const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState)); const runCli = (args: ReadonlyArray, env: Record = {}) => @@ -116,8 +105,8 @@ async function startFakeDesktop(input: { const fakeDesktop = Effect.fn(function* ( input: Omit[0], "platform" | "userId">, ) { - const platform = yield* HostProcessPlatform; - const userId = yield* HostProcessUserId; + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; return yield* Effect.acquireRelease( Effect.promise(() => startFakeDesktop({ ...input, platform, userId })), (server) => Effect.promise(() => server.close()), @@ -148,7 +137,7 @@ describe("t3 server command safety", () => { "C:new-project", ]) { const error = yield* runCli([word, "--base-dir", baseDir]).pipe( - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), Effect.flip, ); expect(String(error)).toContain(`Unknown command "${word}"`); @@ -187,7 +176,7 @@ describe("t3 server command safety", () => { yield* Effect.promise(() => NodeFSP.mkdir(stateDir, { recursive: true })); yield* Effect.promise(() => NodeFSP.writeFile(statePath, record)); const newDirectory = NodePath.join(root, "new-project"); - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; for (const args of [ [], ["start"], @@ -199,7 +188,7 @@ describe("t3 server command safety", () => { ]) { const error = yield* runCli(args, { T3CODE_HOME: baseDir }).pipe( Effect.provideService( - HostProcessPlatform, + HostProcess.Platform, args[0] === "C:new-project" ? "win32" : platform, ), Effect.flip, @@ -240,7 +229,7 @@ describe("t3 app", () => { Effect.gen(function* () { const baseDir = NodePath.join(root, "missing-t3-home"); const error = yield* runCli(["app", "--base-dir", baseDir]).pipe( - Effect.provideService(HostProcessPlatform, "freebsd"), + Effect.provideService(HostProcess.Platform, "freebsd"), Effect.flip, ); @@ -263,7 +252,7 @@ describe("t3 app", () => { expect(error).toMatchObject({ _tag: "DesktopAppUnreachableError", candidateAddresses: [expect.any(String)], - workspaceRoot: yield* HostProcessWorkingDirectory, + workspaceRoot: yield* HostProcess.WorkingDirectory, message: expect.stringContaining("Could not reach the T3 Code desktop app."), cause: { code: "ENOENT" }, }); @@ -277,8 +266,8 @@ describe("t3 app", () => { Effect.gen(function* () { const baseDir = NodePath.join(root, "t3-home"); const explicitPath = NodePath.join(root, "project"); - const platform = yield* HostProcessPlatform; - const workingDirectory = yield* HostProcessWorkingDirectory; + const platform = yield* HostProcess.Platform; + const workingDirectory = yield* HostProcess.WorkingDirectory; const desktop = yield* fakeDesktop({ baseDir }); yield* runCli(["app"], { T3CODE_HOME: baseDir }); @@ -296,7 +285,6 @@ describe("t3 app", () => { it.effect("prefers the installed desktop app when a dev desktop is also running", () => withTempDirectory("t3-app-preferred-test-", (root) => Effect.gen(function* () { - vi.mocked(NodeOS.homedir).mockReturnValue(root); const baseDir = NodePath.join(root, ".t3"); const desktop = yield* fakeDesktop({ baseDir }); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); @@ -305,14 +293,13 @@ describe("t3 app", () => { expect(desktop.received).toHaveLength(1); expect(development.received).toHaveLength(0); - }).pipe(Effect.scoped), + }).pipe(Effect.provideService(HostProcess.HomeDirectory, root), Effect.scoped), ), ); it.effect("finds the dev desktop when the default desktop socket is absent", () => withTempDirectory("t3-app-dev-test-", (root) => Effect.gen(function* () { - vi.mocked(NodeOS.homedir).mockReturnValue(root); const baseDir = NodePath.join(root, ".t3"); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); @@ -321,14 +308,13 @@ describe("t3 app", () => { expect(development.received).toHaveLength(2); expect(yield* pathExists(baseDir)).toBe(false); - }).pipe(Effect.scoped), + }).pipe(Effect.provideService(HostProcess.HomeDirectory, root), Effect.scoped), ), ); it.effect("never searches a dev state directory for an explicit T3 home", () => withTempDirectory("t3-app-explicit-test-", (root) => Effect.gen(function* () { - vi.mocked(NodeOS.homedir).mockReturnValue(root); const baseDir = NodePath.join(root, ".t3"); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); @@ -338,7 +324,7 @@ describe("t3 app", () => { expect(flagError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); expect(envError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); expect(development.received).toHaveLength(0); - }).pipe(Effect.scoped), + }).pipe(Effect.provideService(HostProcess.HomeDirectory, root), Effect.scoped), ), ); @@ -347,7 +333,6 @@ describe("t3 app", () => { (responseKind) => withTempDirectory("t3-app-response-test-", (root) => Effect.gen(function* () { - vi.mocked(NodeOS.homedir).mockReturnValue(root); const baseDir = NodePath.join(root, ".t3"); const desktop = yield* fakeDesktop({ baseDir, @@ -373,7 +358,7 @@ describe("t3 app", () => { _tag: "DesktopAppRequestFailedError", code: "project-create-failed", requestId: desktop.received[0]?.requestId, - workspaceRoot: yield* HostProcessWorkingDirectory, + workspaceRoot: yield* HostProcess.WorkingDirectory, message: expect.stringContaining("project-create-failed"), cause: { ok: false, @@ -387,7 +372,7 @@ describe("t3 app", () => { cause: { message: "The desktop app response is invalid." }, }); } - }).pipe(Effect.scoped), + }).pipe(Effect.provideService(HostProcess.HomeDirectory, root), Effect.scoped), ), ); }); diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts index d9ad57f5127d..eade5887c6c1 100644 --- a/apps/server/src/cli/app.ts +++ b/apps/server/src/cli/app.ts @@ -10,11 +10,7 @@ import { type DesktopAppActivationRequest, } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; -import { - HostProcessPlatform, - HostProcessUserId, - HostProcessWorkingDirectory, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Config from "effect/Config"; import * as Console from "effect/Console"; import * as Crypto from "effect/Crypto"; @@ -188,7 +184,7 @@ const runAppCommand = Effect.fn("cli.app")(function* (flags: { readonly workspaceRoot: Option.Option; }) { const environment = yield* appEnvironment; - const hostPlatform = yield* HostProcessPlatform; + const hostPlatform = yield* HostProcess.Platform; if (Option.isSome(environment.sshConnection) || Option.isSome(environment.sshTty)) { return yield* new DesktopAppSshUnsupportedError({}); } @@ -201,9 +197,9 @@ const runAppCommand = Effect.fn("cli.app")(function* (flags: { const baseDir = yield* resolveBaseDir(configuredBaseDir); const allowDevFallback = Option.isNone(flags.baseDir) && !environment.t3Home?.trim(); const rawWorkspaceRoot = - Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcessWorkingDirectory); + Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcess.WorkingDirectory); const workspaceRoot = path.resolve(yield* expandHomePath(rawWorkspaceRoot)); - const userId = yield* HostProcessUserId; + const userId = yield* HostProcess.UserId; const resolveAddress = (stateSubdirectory: "userdata" | "dev") => resolveDesktopAppControlAddress({ stateDir: path.join(baseDir, stateSubdirectory), diff --git a/apps/server/src/cli/browser.ts b/apps/server/src/cli/browser.ts index 12f74ba60d2d..9bff41cb4fed 100644 --- a/apps/server/src/cli/browser.ts +++ b/apps/server/src/cli/browser.ts @@ -10,11 +10,7 @@ * Both need root. Without it, the command prints what it would change and the * `sudo` line to run. It is safe to run again; it skips what is already done. */ -import { - HostProcessEnvironment, - HostProcessPlatform, - HostProcessUserId, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -61,7 +57,7 @@ const runStep = Effect.fn("browserSetup.runStep")(function* ( * unspecified home falls back to the invoking user's `~/.t3`. */ const setupBaseDir = Effect.fn("browserSetup.baseDir")(function* (explicit: Option.Option) { - const env = yield* HostProcessEnvironment; + const env = yield* HostProcess.Environment; const raw = Option.getOrUndefined(explicit) ?? env.T3CODE_HOME; if (raw !== undefined || env.SUDO_USER === undefined) return yield* resolveBaseDir(raw); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -109,11 +105,11 @@ const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe ), Command.withHandler(({ baseDir }) => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") { + if ((yield* HostProcess.Platform) !== "linux") { return yield* Console.log("Nothing to set up: T3's browser runs as is on this system."); } const fs = yield* FileSystem.FileSystem; - const isRoot = (yield* HostProcessUserId) === 0; + const isRoot = (yield* HostProcess.UserId) === 0; const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); const needsProfile = yield* PreviewBrowserHost.sandboxBlocked; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 83d384097ecc..866cc5ec8927 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -5,7 +5,7 @@ import { type RelayClientInstallProgressStage, } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; @@ -686,7 +686,7 @@ export const connectCommand = Command.make("connect", { // show how to run the server manually. const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); if (background) { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; yield* Console.log( platform === "darwin" ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index bf2dcba2b9f4..5cd89a1457b2 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,12 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; -import { - HostProcessArguments, - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessIsExecutable, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -113,11 +107,11 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { readonly executable?: boolean; }) => resolveRootCliCommand("browser setup").pipe( - Effect.provideService(HostProcessExecutablePath, input.node), - Effect.provideService(HostProcessArguments, [input.node, input.entry]), - Effect.provideService(HostProcessIsExecutable, input.executable ?? false), - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HostProcessEnvironment, { PATH: input.path ?? "", ...input.env }), + Effect.provideService(HostProcess.ExecutablePath, input.node), + Effect.provideService(HostProcess.Arguments, [input.node, input.entry]), + Effect.provideService(HostProcess.IsExecutable, input.executable ?? false), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { PATH: input.path ?? "", ...input.env }), ); /** A directory holding an executable `t3`, to stand in for one on PATH. */ @@ -201,9 +195,9 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); yield* fs.writeFileString(entry, ""); const installation = yield* resolveServerInstallation.pipe( - Effect.provideService(HostProcessArguments, ["node", entry]), - Effect.provideService(HostProcessExecutablePath, entry), - Effect.provideService(HostProcessIsExecutable, entry.endsWith("/t3")), + Effect.provideService(HostProcess.Arguments, ["node", entry]), + Effect.provideService(HostProcess.ExecutablePath, entry), + Effect.provideService(HostProcess.IsExecutable, entry.endsWith("/t3")), ); expect(installation).toEqual({ kind }); } @@ -227,9 +221,9 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}', ); const resolve = resolveServerInstallation.pipe( - Effect.provideService(HostProcessArguments, ["node", entry]), - Effect.provideService(HostProcessIsExecutable, false), - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Arguments, ["node", entry]), + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.Platform, "linux"), ); expect(yield* resolve).toBeNull(); yield* fs.symlink(entry, globalBin); @@ -237,7 +231,7 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { yield* fs.remove(globalBin); yield* fs.writeFileString(globalBin, "an unrelated t3 command"); expect(yield* resolve).toBeNull(); - expect(yield* resolve.pipe(Effect.provideService(HostProcessPlatform, "win32"))).toBeNull(); + expect(yield* resolve.pipe(Effect.provideService(HostProcess.Platform, "win32"))).toBeNull(); }), ); @@ -261,9 +255,9 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { ); yield* fs.symlink(launcher, path.join(prefix, "bin/t3")); const resolve = resolveServerInstallation.pipe( - Effect.provideService(HostProcessExecutablePath, entry), - Effect.provideService(HostProcessIsExecutable, true), - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.ExecutablePath, entry), + Effect.provideService(HostProcess.IsExecutable, true), + Effect.provideService(HostProcess.Platform, "linux"), ); for (const [version, expected] of [ ["0.0.44", null], @@ -296,8 +290,8 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { } expect( yield* resolveServerInstallation.pipe( - Effect.provideService(HostProcessArguments, ["node", entry]), - Effect.provideService(HostProcessIsExecutable, false), + Effect.provideService(HostProcess.Arguments, ["node", entry]), + Effect.provideService(HostProcess.IsExecutable, false), ), ).toBeNull(); } diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 59843abd7289..ff5cb9d8f9c8 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -5,13 +5,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type { ServerInstallation } from "@t3tools/contracts"; -import { - HostProcessArguments, - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessIsExecutable, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { isCommandAvailable } from "@t3tools/shared/shell"; import packageJson from "../../package.json" with { type: "json" }; @@ -60,10 +54,10 @@ const decodeInstallManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(I export const resolveServerInstallation = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const args = yield* HostProcessArguments; - const executable = yield* HostProcessIsExecutable; - const executablePath = yield* HostProcessExecutablePath; - const platform = yield* HostProcessPlatform; + const args = yield* HostProcess.Arguments; + const executable = yield* HostProcess.IsExecutable; + const executablePath = yield* HostProcess.ExecutablePath; + const platform = yield* HostProcess.Platform; const entry = yield* fs.realPath(executable ? executablePath : (args[1] ?? "")); const match = /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/@t3code\/t3-[^/]+\/t3)$/.exec( @@ -141,7 +135,7 @@ export function formatCliCommand(input: { /** `formatCliCommand` against this process's real entry path and version. */ export const resolveCliCommand = (subcommand: string) => - Effect.map(HostProcessArguments, (processArguments) => + Effect.map(HostProcess.Arguments, (processArguments) => formatCliCommand({ subcommand, entryPath: processArguments[1] ?? "", @@ -161,12 +155,12 @@ const shellWord = (value: string) => */ const resolveInstallLauncher = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const shim = (yield* HostProcessEnvironment).T3CODE_CLI_PATH?.trim(); + const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim(); if (shim && (yield* fs.exists(shim).pipe(Effect.orElseSucceed(() => false)))) { return Option.some(shim); } - return (yield* HostProcessIsExecutable) - ? Option.some(yield* HostProcessExecutablePath) + return (yield* HostProcess.IsExecutable) + ? Option.some(yield* HostProcess.ExecutablePath) : Option.none(); }); @@ -197,7 +191,7 @@ export const resolveRootCliCommand = (subcommand: string) => Effect.gen(function* () { const { command, launcher } = yield* resolveHostCliCommand(subcommand); if (launcher) return `sudo ${command}`; - const executablePath = yield* HostProcessExecutablePath; + const executablePath = yield* HostProcess.ExecutablePath; const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => executablePath.startsWith(`${directory}/`), ); diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index fa7c6b7588fd..56a84bba7177 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -7,7 +7,7 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -184,7 +184,7 @@ describe("t3 pair", () => { }), ).pipe( Effect.provide(NodeServices.layer), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL, diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index 3062a840e59e..8c8dd9237cb9 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -1,4 +1,4 @@ -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -32,7 +32,7 @@ export const runDefaultServerCommand = (flags: CliServerFlags) => if (Option.isSome(flags.cwd)) { const cwd = flags.cwd.value.trim(); const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const explicitPath = cwd === "." || cwd === ".." || diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index f58fe4a17815..077274fc2159 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; @@ -136,7 +136,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands "--base-dir", baseDir, ]).pipe( - Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(HostProcess.Environment, {}), Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), ); @@ -161,7 +161,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands "--base-dir", baseDir, ]).pipe( - Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(HostProcess.Environment, {}), Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), Effect.flip, ); @@ -190,7 +190,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands baseDir, "--allow-downgrade", ]).pipe( - Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(HostProcess.Environment, {}), Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), ); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 6b799f58e23e..7a5dd7337478 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -1,4 +1,4 @@ -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -241,7 +241,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { } // A LaunchAgent starts at login and dies at logout; there is no // enable-linger equivalent on macOS. Do not promise more than that. - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const wanted = yield* Prompt.run( Prompt.Confirm({ message: installed diff --git a/apps/server/src/cli/theme.test.ts b/apps/server/src/cli/theme.test.ts index edf309ccc33b..5dd350043dbe 100644 --- a/apps/server/src/cli/theme.test.ts +++ b/apps/server/src/cli/theme.test.ts @@ -14,11 +14,11 @@ import { Command } from "effect/cli"; import { cli } from "../binCli.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; // These force a failure with chmod, which Windows ignores for directories and // cannot use to make a file unreadable, so the failure never happens there. -const windowsHost = HostProcessPlatform.defaultValue() === "win32"; +const windowsHost = HostProcess.Platform.defaultValue() === "win32"; const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args).pipe( diff --git a/apps/server/src/cli/trace.test.ts b/apps/server/src/cli/trace.test.ts index b81a3ed53f25..ac6b4751eeb6 100644 --- a/apps/server/src/cli/trace.test.ts +++ b/apps/server/src/cli/trace.test.ts @@ -16,13 +16,18 @@ function span(name: string, durationMs: number, endMs: number, exitTag = "Succes }); } -function browserSpan(name: string, status: { code: string; message?: string }) { +function browserSpan( + name: string, + status: { code: string; message?: string }, + attributes: Record = {}, +) { return JSON.stringify({ type: "otlp-span", name, durationMs: 1, endTimeUnixNano: "1000000", status, + attributes, }); } @@ -91,10 +96,12 @@ it("reads failures and interrupts of browser spans from their OTLP status", () = const summarizer = makeTraceSpanSummary(); [ browserSpan("render", { code: "2", message: "boom" }), + browserSpan("render", { code: "0" }, { "effect.fiber.interrupted": true }), + // Clients before Effect 4.0.2. browserSpan("render", { code: "1", message: "Interrupted" }), browserSpan("render", { code: "1" }), ].forEach(summarizer.addLine); const [render] = summarizer.finish().spans; - assert.deepStrictEqual([render?.count, render?.interrupted, render?.failures], [3, 1, 1]); + assert.deepStrictEqual([render?.count, render?.interrupted, render?.failures], [4, 2, 1]); }); diff --git a/apps/server/src/cli/trace.ts b/apps/server/src/cli/trace.ts index 744c5957441b..032e50ccfe90 100644 --- a/apps/server/src/cli/trace.ts +++ b/apps/server/src/cli/trace.ts @@ -30,13 +30,18 @@ const decodeTraceSpanLine = Schema.decodeUnknownOption( // Server (`effect-span`) records. exit: Schema.optional(Schema.Struct({ _tag: Schema.String })), // Browser (`otlp-span`) records. Effect's OTLP tracer writes code "2" for - // errors and code "1" with message "Interrupted" for interrupts. + // errors. It marks interrupts with an `effect.fiber.interrupted` + // attribute; clients before Effect 4.0.2 wrote code "1" with message + // "Interrupted" instead. status: Schema.optional( Schema.Struct({ code: Schema.optional(Schema.String), message: Schema.optional(Schema.String), }), ), + attributes: Schema.optional( + Schema.Struct({ "effect.fiber.interrupted": Schema.optional(Schema.Unknown) }), + ), }), ), ); @@ -77,7 +82,11 @@ export function makeTraceSpanSummary(sinceMs = -Infinity) { lastEndMs = Math.max(lastEndMs, endMs); const stats = byName.get(span.name) ?? { durations: [], interrupted: 0, failures: 0 }; stats.durations.push(span.durationMs); - if (span.exit?._tag === "Interrupted" || span.status?.message === "Interrupted") { + if ( + span.exit?._tag === "Interrupted" || + span.attributes?.["effect.fiber.interrupted"] === true || + span.status?.message === "Interrupted" + ) { stats.interrupted += 1; } if (span.exit?._tag === "Failure" || span.status?.code === "2") stats.failures += 1; diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index a7bd06fab678..7bce1c0251ca 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -14,7 +14,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeOS from "node:os"; import * as NodeReadlinePromises from "node:readline/promises"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Console from "effect/Console"; @@ -193,7 +193,7 @@ export const triageCommand = Command.make("triage", { releaseTag: /^[^-+]+-(?:nightly|preview)\./.test(version) ? `v${version} (prerelease build; if this tag does not exist, clone main)` : `v${version}`, - os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`, + os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`, nodeVersion: process.version, launchedAs: yield* resolveCliCommand("triage"), server: yield* describeServerProcess(paths.serverRuntimeStatePath), diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 02860f529440..1e81e8b8c7a9 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -3,7 +3,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { findOwnedLauncher } from "./uninstall.ts"; @@ -32,6 +32,6 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); - }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); }); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 4b977bc24951..deec288ef6e8 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -4,11 +4,7 @@ // cannot express: it kills the child when the scope closes. import * as NodeChildProcess from "node:child_process"; -import { - HostProcessEnvironment, - HostProcessIsExecutable, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -58,7 +54,7 @@ export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(functi }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; if (input.launchedAs === undefined) return undefined; if (platform === "win32") { const shimPath = yield* findWindowsShim(input.launchedAs); @@ -87,7 +83,7 @@ const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: { path.resolve(status.installedBaseDir) === path.resolve(input.baseDir); const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); const runtimeDir = path.dirname(versionsDir); - const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined; + const launchedAs = (yield* HostProcess.IsExecutable) ? yield* resolveLauncherPath : undefined; const plan: UninstallPlan = { service: status.supported && status.installed && servesThisHome, launcher: yield* findOwnedLauncher({ launchedAs, versionsDir }), @@ -128,14 +124,14 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { readonly assumeYes: boolean; }) { const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; - const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; const service = yield* BootService.BootService; const plan = yield* planUninstall({ baseDir: input.baseDir }); if (!plan.service && plan.launcher === undefined && plan.runtimeDir === undefined) { yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`); - if (!(yield* HostProcessIsExecutable)) { + if (!(yield* HostProcess.IsExecutable)) { yield* Console.log( " This t3 runs from a Node script, so it was installed by npm or built from source. Remove it the same way (`npm uninstall -g t3`, or delete the checkout).", ); diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 1a9c66428da9..8664e4ba5b45 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -4,12 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { - HostProcessEnvironment, - HostProcessInvokedAs, - HostProcessPlatform, - HostProcessWorkingDirectory, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { repointLauncher, resolveLauncherPath } from "./update.ts"; @@ -37,7 +32,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { assert.deepStrictEqual(Option.getOrUndefined(repointed), launcher); assert.equal(yield* fs.readLink(launcher), newExe); - }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); it.effect("leaves a plain copy or a foreign symlink alone", () => @@ -71,7 +66,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { } assert.equal(yield* fs.readLink(foreign), elsewhere); assert.equal(yield* fs.readLink(otherLauncher), otherHome); - }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); it.effect("finds the launcher a bare command name resolved to on PATH", () => @@ -84,26 +79,26 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { yield* fs.writeFileString(launcher, ""); const bare = yield* resolveLauncherPath.pipe( - Effect.provideService(HostProcessInvokedAs, "t3"), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.InvokedAs, "t3"), + Effect.provideService(HostProcess.Environment, { PATH: `${path.join(root, "missing")}:${path.join(root, "bin")}`, }), - Effect.provideService(HostProcessWorkingDirectory, root), + Effect.provideService(HostProcess.WorkingDirectory, root), ); const relative = yield* resolveLauncherPath.pipe( - Effect.provideService(HostProcessInvokedAs, "./bin/t3"), - Effect.provideService(HostProcessEnvironment, { PATH: "" }), - Effect.provideService(HostProcessWorkingDirectory, root), + Effect.provideService(HostProcess.InvokedAs, "./bin/t3"), + Effect.provideService(HostProcess.Environment, { PATH: "" }), + Effect.provideService(HostProcess.WorkingDirectory, root), ); const absent = yield* resolveLauncherPath.pipe( - Effect.provideService(HostProcessInvokedAs, "t3"), - Effect.provideService(HostProcessEnvironment, { PATH: path.join(root, "missing") }), - Effect.provideService(HostProcessWorkingDirectory, root), + Effect.provideService(HostProcess.InvokedAs, "t3"), + Effect.provideService(HostProcess.Environment, { PATH: path.join(root, "missing") }), + Effect.provideService(HostProcess.WorkingDirectory, root), ); assert.equal(bare, launcher); assert.equal(relative, launcher); assert.equal(absent, undefined); - }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); }); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 576158e316bc..073217d505cb 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -1,11 +1,4 @@ -import { - HostProcessArchitecture, - HostProcessEnvironment, - HostProcessInvokedAs, - HostProcessIsExecutable, - HostProcessPlatform, - HostProcessWorkingDirectory, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { CLI_RELEASE_BASE_URL_ENV, CLI_RELEASE_CHANNELS, @@ -121,7 +114,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; if (input.launchedAs === undefined) return Option.none(); const ownsTarget = (candidate: string) => launcherOwnsVersionsDir(path, input.versionsDir, candidate); @@ -170,10 +163,10 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function export const resolveLauncherPath = Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const invokedAs = yield* HostProcessInvokedAs; - const cwd = yield* HostProcessWorkingDirectory; - const environment = yield* HostProcessEnvironment; - const platform = yield* HostProcessPlatform; + const invokedAs = yield* HostProcess.InvokedAs; + const cwd = yield* HostProcess.WorkingDirectory; + const environment = yield* HostProcess.Environment; + const platform = yield* HostProcess.Platform; if (invokedAs.includes("/") || invokedAs.includes("\\")) { return path.resolve(cwd, invokedAs); } @@ -198,7 +191,7 @@ export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(functio ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* HostProcessEnvironment; + const environment = yield* HostProcess.Environment; const candidates = [ ...(environment["T3CODE_INSTALL_BIN_DIR"] ? [environment["T3CODE_INSTALL_BIN_DIR"]] : []), ...(environment["PATH"] ?? environment["Path"] ?? "").split(";"), @@ -299,7 +292,7 @@ const findForegroundServer = Effect.fn("cli.update.find_foreground_server")(func const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(function* ( pid: number, ) { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const fs = yield* FileSystem.FileSystem; const runner = yield* ProcessRunner.ProcessRunner; if (platform === "linux") { @@ -340,9 +333,9 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; - const platform = yield* HostProcessPlatform; - const arch = yield* HostProcessArchitecture; - const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcess.Platform; + const arch = yield* HostProcess.Architecture; + const environment = yield* HostProcess.Environment; const httpClient = yield* HttpClient.HttpClient; const service = yield* BootService.BootService; @@ -531,7 +524,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { ), ); - const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined; + const launchedAs = (yield* HostProcess.IsExecutable) ? yield* resolveLauncherPath : undefined; const repointed = yield* repointLauncher({ launchedAs, versionsDir: path.dirname(runtime.versionDir), diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 85de4a7bd437..65bf0818a970 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1,10 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { - HostProcessExecutablePath, - HostProcessPlatform, - HostProcessUserId, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -226,9 +222,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provide( Layer.mergeAll( - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/t3"), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.UserId, 501), + Layer.succeed(HostProcess.ExecutablePath, "/usr/bin/t3"), Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("no release download expected")), diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0b102579887a..294f229affa9 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,9 +1,4 @@ -import { - HostProcessArchitecture, - HostProcessExecutablePath, - HostProcessPlatform, - HostProcessUserId, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -558,10 +553,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { readonly cliVersion: string; readonly host?: BootServiceHost; }) { - const hostExecPath = yield* HostProcessExecutablePath; - const platform = yield* HostProcessPlatform; - const arch = yield* HostProcessArchitecture; - const uid = yield* HostProcessUserId; + const hostExecPath = yield* HostProcess.ExecutablePath; + const platform = yield* HostProcess.Platform; + const arch = yield* HostProcess.Architecture; + const uid = yield* HostProcess.UserId; const httpClient = yield* HttpClient.HttpClient; const releaseBaseUrl = Option.getOrUndefined( yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 111b62c8a481..443fad607fb8 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -120,8 +120,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( }, ), Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)), - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Architecture, "x64"), Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); return { selfUpdate, order }; diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 47c4949de739..615351c4395e 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -6,7 +6,7 @@ import { type ServerSelfUpdateResult, type ThreadId, } from "@t3tools/contracts"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -176,8 +176,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const runner = yield* ProcessRunner.ProcessRunner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const arch = yield* HostProcessArchitecture; + const platform = yield* HostProcess.Platform; + const arch = yield* HostProcess.Architecture; // Archive-distributed targets download from GitHub Releases. The client is // optional so callers without one (tests, npm-only hosts) still construct. const httpClient = yield* HttpClient.HttpClient; diff --git a/apps/server/src/cloud/serviceLauncherClient.test.ts b/apps/server/src/cloud/serviceLauncherClient.test.ts index eab0935419a0..dee00b0b203c 100644 --- a/apps/server/src/cloud/serviceLauncherClient.test.ts +++ b/apps/server/src/cloud/serviceLauncherClient.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "@effect/vitest"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -45,7 +45,7 @@ class FakeLauncherProcess { const makeClient = (host: FakeLauncherProcess, currentVersion: string) => ServiceLauncherClient.make({ currentVersion }).pipe( Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, host), - Effect.provideService(HostProcessEnvironment, host.env), + Effect.provideService(HostProcess.Environment, host.env), ); it.effect("waits for the launcher to durably commit the trial update ID", () => diff --git a/apps/server/src/cloud/serviceLauncherClient.ts b/apps/server/src/cloud/serviceLauncherClient.ts index 2d644157211f..b58d133494cb 100644 --- a/apps/server/src/cloud/serviceLauncherClient.ts +++ b/apps/server/src/cloud/serviceLauncherClient.ts @@ -1,5 +1,5 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -114,7 +114,7 @@ export class ServiceLauncherClient extends Context.Service< const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup")( function* (options?: { readonly currentVersion?: string }) { const host = yield* ServiceLauncherHostProcess; - const environment = yield* HostProcessEnvironment; + const environment = yield* HostProcess.Environment; const currentVersion = options?.currentVersion ?? packageJson.version; const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV]; const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext); diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index a5a669d1d8ba..f3a21fe9b362 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -5,7 +5,7 @@ * types `agent-device …` and gets the version the injected instructions were * written for, regardless of what is or is not globally installed. */ -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -20,7 +20,7 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio const { entryPath } = input; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const node = yield* resolveNodeExecutable("Device automation"); const shimDir = path.join(input.stateDir, SHIM_DIR); yield* fs.makeDirectory(shimDir, { recursive: true }); diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 4dfd5e20d1c8..2cedbe1e8bed 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -1,9 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses. -import { - HostProcessExecutablePath, - HostProcessIsExecutable, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as NodeChildProcess from "node:child_process"; import * as NodeUtil from "node:util"; @@ -26,7 +22,7 @@ describe("host-bound agent commands", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" }); - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const dir = path.join( temp, platform === "win32" ? "paths with spaces" : "quotes '\" $HOME `literal`", @@ -92,8 +88,8 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, } }).pipe( Effect.scoped, - Effect.provideService(HostProcessIsExecutable, true), - Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provideService(HostProcess.IsExecutable, true), + Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"), Effect.provide(NodeServices.layer), ), ); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index f30960ed2212..af590787e4ee 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -26,9 +26,9 @@ import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; -export const DEVICE_HUB_VERSION = "0.12.0"; +export const DEVICE_HUB_VERSION = "0.15.3"; const AGENT_DEVICE_PACKAGE = "agent-device"; -export const AGENT_DEVICE_VERSION = "0.21.12"; +export const AGENT_DEVICE_VERSION = "0.21.23"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 7dde6263defd..9b404099e1dd 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodePath from "@effect/platform-node/NodePath"; -import { - HostProcessEnvironment, - HostProcessPlatform, - HostProcessIsExecutable, - HostProcessUserId, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -26,8 +21,8 @@ const diagnose = ( platform: NodeJS.Platform = "darwin", ) => LocalDeviceHost.__testing.platformReason("android").pipe( - Effect.provideService(HostProcessEnvironment, environment), - Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcess.Environment, environment), + Effect.provideService(HostProcess.Platform, platform), Effect.provideService( FileSystem.FileSystem, FileSystem.makeNoop({ @@ -155,8 +150,8 @@ it.effect( ] as const) { const environment = { PATH: "/usr/bin", XDG_RUNTIME_DIR: runtimeDir }; const result = yield* LocalDeviceHost.__testing.hubEnvironment(environment).pipe( - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HostProcessUserId, uid), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.UserId, uid), Effect.provideService( FileSystem.FileSystem, FileSystem.makeNoop({ @@ -176,8 +171,8 @@ it.effect( const missing = yield* LocalDeviceHost.__testing .hubEnvironment({}) .pipe( - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HostProcessUserId, 1000), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.UserId, 1000), Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), ); expect(missing.XDG_RUNTIME_DIR).toBeUndefined(); @@ -192,8 +187,8 @@ it.effect( const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-consent-" }); const host = yield* LocalDeviceHost.make().pipe( Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)), - Effect.provideService(HostProcessEnvironment, { HOME: baseDir, PATH: "" }), - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }), + Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => @@ -213,7 +208,7 @@ it.effect( expect(yield* host.current).toBeNull(); const error = yield* host .ensureReady(() => Effect.die("Must not install without Node")) - .pipe(Effect.flip, Effect.provideService(HostProcessIsExecutable, true)); + .pipe(Effect.flip, Effect.provideService(HostProcess.IsExecutable, true)); expect(error.message).toContain("Local device support requires Node.js"); expect(error.message).toContain("Install Node.js"); yield* host.stop; diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 58179151877c..779221ab93cf 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -19,11 +19,7 @@ import { LOCAL_DEVICE_HOST_ID, } from "@t3tools/contracts"; import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; -import { - HostProcessEnvironment, - HostProcessPlatform, - HostProcessUserId, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, type NodeRuntimeUnavailableError, @@ -107,7 +103,7 @@ interface RunningHost { const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* ( platform: DevicePlatform, ): Effect.fn.Return { - const hostPlatform = yield* HostProcessPlatform; + const hostPlatform = yield* HostProcess.Platform; if (platform === "ios") { if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode."; if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found."; @@ -132,8 +128,8 @@ const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* ( const androidSdk = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* HostProcessEnvironment; - const platform = yield* HostProcessPlatform; + const environment = yield* HostProcess.Environment; + const platform = yield* HostProcess.Platform; const home = environment.HOME ?? environment.USERPROFILE ?? ""; const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim(); const candidates = explicit @@ -208,8 +204,8 @@ const hubEnvironment = Effect.fn("LocalDeviceHost.hubEnvironment")(function* ( environment: NodeJS.ProcessEnv, ) { const env: NodeJS.ProcessEnv = { ...environment, FORCE_COLOR: "0", NO_COLOR: "1" }; - const platform = yield* HostProcessPlatform; - const uid = yield* HostProcessUserId; + const platform = yield* HostProcess.Platform; + const uid = yield* HostProcess.UserId; if (platform === "linux" && env.XDG_RUNTIME_DIR === undefined && uid !== undefined) { // SSH sessions may omit the directory where the emulator publishes its gRPC token. const runtimeDir = `/run/user/${uid}`; @@ -234,8 +230,8 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const net = yield* NetService.NetService; const runner = yield* ProcessRunner.ProcessRunner; const httpClient = yield* HttpClient.HttpClient; - const environment = yield* HostProcessEnvironment; - const hostPlatform = yield* HostProcessPlatform; + const environment = yield* HostProcess.Environment; + const hostPlatform = yield* HostProcess.Platform; const sdk = yield* androidSdk; const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path); const startLock = yield* Semaphore.make(1); @@ -406,7 +402,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { stderr: "pipe", env: yield* hubEnvironment(hostEnvironment).pipe( Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(HostProcessPlatform, hostPlatform), + Effect.provideService(HostProcess.Platform, hostPlatform), ), }, ), @@ -598,7 +594,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), - Effect.provideService(HostProcessPlatform, hostPlatform), + Effect.provideService(HostProcess.Platform, hostPlatform), ); const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index f1f56c715e60..97f124721bb0 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - verifies generated remote scripts using real shell and Node processes. import * as Effect from "effect/Effect"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; @@ -14,7 +14,7 @@ const exec = NodeUtil.promisify(NodeChildProcess.execFile); it.effect("finds Android Studio Java for a non-interactive SSH session", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; + if ((yield* HostProcess.Platform) === "win32") return; yield* Effect.promise(async () => { const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-")); try { @@ -38,7 +38,7 @@ it.effect("finds Android Studio Java for a non-interactive SSH session", () => it.effect("preserves shell metacharacters and newlines in remote arguments", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; + if ((yield* HostProcess.Platform) === "win32") return; yield* Effect.promise(async () => { const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line"; const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]); @@ -50,7 +50,7 @@ it.effect("preserves shell metacharacters and newlines in remote arguments", () describe("remote helper lifecycle", () => { it.effect("reuses its own healthy helpers and stops only its own runtime", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; + if ((yield* HostProcess.Platform) === "win32") return; yield* Effect.promise(async () => { const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-")); const bin = NodePath.join(home, "bin"); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 1e222abb867e..8a90a9345df3 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -1,5 +1,5 @@ import { it } from "@effect/vitest"; -import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -54,7 +54,7 @@ const resolveTargets = (input: { readonly hostname: string; }) => Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( - Effect.provideService(HostProcessHostname, input.hostname), + Effect.provideService(HostProcess.Hostname, input.hostname), Effect.provide( RemoteOpenTargets.layer.pipe( Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))), diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index e0acfaee2f48..1cd5751bc2d4 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -9,7 +9,7 @@ * on the tailnet; `.local` only on the same LAN). */ import { type RemoteOpenTarget } from "@t3tools/contracts"; -import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import { readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; @@ -58,7 +58,7 @@ export const make = Effect.gen(function* () { // os.hostname() may already be an FQDN (macOS often reports // "Name.local"); mDNS names are always `.local`. - const hostname = yield* HostProcessHostname; + const hostname = yield* HostProcess.Hostname; const shortHostname = hostname.split(".")[0]?.trim(); if (shortHostname !== undefined && shortHostname.length > 0) { targets.push({ kind: "mdns", host: `${shortHostname}.local` }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 41c18b907437..f9e13212252f 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -10,12 +10,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; -import { - HostProcessArguments, - HostProcessEnvironment, - HostProcessIsExecutable, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -112,10 +107,10 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { Layer.provide(ServerConfig.layer({ ...config, mode })), ), ), - Effect.provideService(HostProcessArguments, ["node", entry]), - Effect.provideService(HostProcessIsExecutable, false), - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(HostProcess.Arguments, ["node", entry]), + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, {}), ); expect(descriptor.capabilities.serverInstallation).toEqual( mode === "web" ? { kind: "npm-global", prefix } : undefined, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0d5de130de32..7ad4cf7013a8 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -4,7 +4,7 @@ import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -186,8 +186,8 @@ export const make = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const secrets = yield* ServerSecretStore.ServerSecretStore; const identity = yield* ServerEnvironmentIdentity; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; + const hostPlatform = yield* HostProcess.Platform; + const hostArchitecture = yield* HostProcess.Architecture; const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts index fd7f8ccb3726..7fd418e2821e 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -7,7 +7,7 @@ import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; @@ -52,8 +52,8 @@ const withHostPlatform = ( ) => Layer.mergeAll( layer, - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessHostname, hostname), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.Hostname, hostname), ); afterEach(() => { diff --git a/apps/server/src/environment/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts index 4a5d0a009b9a..73ff4a763910 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.ts @@ -1,4 +1,4 @@ -import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -151,7 +151,7 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( }); const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; if (platform === "darwin") { return yield* runFriendlyLabelCommand({ probe: "macos-computer-name", @@ -187,7 +187,7 @@ export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironment return friendlyHostLabel; } - const hostname = normalizeLabel(yield* HostProcessHostname); + const hostname = normalizeLabel(yield* HostProcess.Hostname); if (hostname) { return hostname; } diff --git a/apps/server/src/environment/ServerEnvironmentMachine.test.ts b/apps/server/src/environment/ServerEnvironmentMachine.test.ts index 3c2ab52a7083..198aa4ed5583 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.test.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.test.ts @@ -4,7 +4,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; @@ -52,7 +52,7 @@ const layerDmiFileSystem = (files: Readonly>) => }); const withPlatform = (platform: NodeJS.Platform, fileSystem = FileSystem.layerNoop({})) => - Layer.mergeAll(layerProcessRunnerTest, fileSystem, Layer.succeed(HostProcessPlatform, platform)); + Layer.mergeAll(layerProcessRunnerTest, fileSystem, Layer.succeed(HostProcess.Platform, platform)); afterEach(() => { runMock.mockReset(); diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts index e9f5e51af600..267dd42a532c 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -1,5 +1,5 @@ import type { EnvironmentMachineKind } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -161,7 +161,7 @@ const detectLinuxMachineKind = Effect.fn("detectLinuxMachineKind")(function* () export const detectServerEnvironmentMachineKind = Effect.fn("detectServerEnvironmentMachineKind")( function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; switch (platform) { case "darwin": return yield* detectDarwinMachineKind(); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9f5f368d2cf2..45f4ae96a133 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -43,8 +43,11 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; -import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import { decodeGitHubPullRequestListJson } from "@t3tools/source-control-github/server/gitHubPullRequests"; +import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -53,12 +56,13 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; -import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; +import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; +import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider"; import { ForgejoPullRequestSchema, toForgejoChangeRequest, -} from "../sourceControl/forgejoPullRequests.ts"; -import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; +} from "@t3tools/source-control-forgejo/server/forgejoPullRequests"; +import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; @@ -543,6 +547,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { kind: "github", + // The GitHub provider's own lookup rule and template convention, which GitManager reads + // instead of the kind. + headBranchProbe: ({ headSelectors }) => ({ + headSelectors: headSelectors.filter((selector) => !selector.includes(":")), + limit: 100, + }), + listChangeRequests: (input) => input.state === "open" ? execute({ @@ -743,7 +754,18 @@ function makeManager(input?: { ); const layerSourceControlRegistry = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe( + Effect.gen(function* () { + // GitHub reads its PR template with git, which the fake gh cannot answer; give the fake + // the package's real reader over the test repository's git. + const git = yield* GitVcsDriver.GitVcsDriver; + return ( + input?.sourceControlProvider ?? { + ...fakeGitHubProvider, + readChangeRequestTemplate: ({ cwd, treeish }: { cwd: string; treeish: string }) => + GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute), + } + ); + }).pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ resolveLink: (input) => provider.resolveLink?.(input), @@ -767,9 +789,12 @@ function makeManager(input?: { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, ), - layerVcsDriver, layerServerSettings, - ).pipe(Layer.provideMerge(layerSourceControlRegistry), Layer.provideMerge(NodeServices.layer)); + ).pipe( + Layer.provideMerge(layerSourceControlRegistry), + Layer.provideMerge(layerVcsDriver), + Layer.provideMerge(NodeServices.layer), + ); // Built into the test's scope: the manager reads these stores after this returns. const layerStores = Layer.merge(ProjectionStore.layer, ProjectStore.layer).pipe( Layer.provideMerge(SqlitePersistence.layerMemory), @@ -2108,17 +2133,19 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { source_project: { path_with_namespace: "Group/Subgroup/Fork" }, }, ]); - const calls: VcsProcess.VcsProcessInput[] = []; + const calls: SourceControlHost.SourceControlProcessInput[] = []; const provider = yield* GitLabSourceControlProvider.make.pipe( Effect.provide( GitLabCli.layer.pipe( Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => - Effect.sync(() => { - calls.push(input); - return fakeGhOutput(output); - }), + TestSourceControlHost.layer({ + process: { + run: (input) => + Effect.sync(() => { + calls.push(input); + return fakeGhOutput(output); + }), + }, }), ), ), @@ -4228,7 +4255,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }; const repository = GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( `https://forgejo.example/forgejo/${owner}/project.git`, - "forgejo", + { repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl }, ); expect(repository).toBe(`${owner}/project`); const context = { @@ -4249,13 +4276,13 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect( GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( "git@forgejo.example:maria/project.git", - "forgejo", + { repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl }, ), ).toBe("maria/project"); expect( GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( "https://gitlab.example/group/maria/project.git", - "gitlab", + {}, ), ).toBe("group/maria/project"); }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 593910ba9a4a..8ca0b6b976a9 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -35,7 +35,6 @@ import { ModelSelection, type ProjectId, SourceControlProviderError, - type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, type ThreadPullRequestKey, @@ -81,8 +80,8 @@ import { detachStackFrame } from "./detachStackFrame.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { @@ -314,27 +313,20 @@ function resolvePullRequestWorktreeLocalBranchName( return `t3code/pr-${pullRequest.number}/${suffix}`; } +/** The repository's `owner/name` from a remote URL, as `provider` reads its remote paths. */ export function parseRepositoryNameWithOwnerFromRemoteUrl( url: string | null, - providerKind?: ChangeRequest["provider"], + provider?: Pick< + SourceControlProvider.SourceControlProvider["Service"], + "repositoryNameFromRemoteUrl" + >, ): string | null { const trimmed = url?.trim() ?? ""; - if (trimmed.length === 0) { - return null; - } - - const match = - /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( - trimmed, - ); - const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; - // Forgejo HTTP paths can include an installation mount; its API always names owner/repo. - if (providerKind === "forgejo" && /^https?:\/\//iu.test(trimmed)) { - return repositoryNameWithOwner.length > 0 - ? repositoryNameWithOwner.split("/").slice(-2).join("/") - : null; - } - return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; + if (trimmed.length === 0) return null; + return ( + provider?.repositoryNameFromRemoteUrl?.(trimmed) ?? + SourceControlProvider.repositoryPathFromRemoteUrl(trimmed) + ); } function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { @@ -625,24 +617,18 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } -// Without the owner selector, a bare branch name also lists same-named -// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full -// page and let matchesBranchHeadContext pick the right head. gh fetches up to -// 100 in one request, and GitHub prices a first:100 connection like first:1. -const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100; - -// `gh pr list --head` filters on the head ref name alone and accepts anything, so an -// `owner:branch` or `remote:branch` selector silently lists zero pull requests -// while spending a GraphQL call. Git branch names cannot contain ":", and the -// bare head branch is always among the selectors, so GitHub probes skip them and -// leave the owner check to matchesBranchHeadContext. -function probeableHeadSelectors( - providerKind: SourceControlProviderKind, +/** The selectors and page size `provider` asks about when looking up a branch's change requests. */ +function headBranchProbe( + provider: SourceControlProvider.SourceControlProvider["Service"], headSelectors: ReadonlyArray, -): ReadonlyArray { - return providerKind === "github" - ? headSelectors.filter((selector) => !selector.includes(":")) - : headSelectors; + state: "open" | "all", +) { + return ( + provider.headBranchProbe?.({ headSelectors, state }) ?? { + headSelectors, + limit: state === "open" ? 1 : 20, + } + ); } function appendUnique(values: string[], next: string | null | undefined): void { @@ -1430,15 +1416,14 @@ export const make = Effect.gen(function* () { /^https?:\/\//iu.test(remoteUrl) && (repositoryNameWithOwner?.split("/").length ?? 0) > 2 ) { - const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl); - const kind = - detected?.kind === "unknown" - ? yield* sourceControlProvider(cwd).pipe( - Effect.map((provider) => provider.kind), - Effect.orElseSucceed(() => undefined), - ) - : detected?.kind; - repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind); + const kind = detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind; + const provider = + kind === undefined + ? undefined + : yield* ( + kind === "unknown" ? sourceControlProvider(cwd) : sourceControlProviders.get(kind) + ).pipe(Effect.orElseSucceed(() => undefined)); + repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, provider); } return { remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, @@ -1701,13 +1686,13 @@ export const make = Effect.gen(function* () { >, ) { const provider = yield* sourceControlProvider(cwd); - const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); - for (const headSelector of headSelectors) { + const probe = headBranchProbe(provider, headContext.headSelectors, "open"); + for (const headSelector of probe.headSelectors) { const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "open", - limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, + limit: probe.limit, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -1733,12 +1718,13 @@ export const make = Effect.gen(function* () { const parsedByNumber = new Map(); const provider = yield* sourceControlProvider(cwd); - for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { + const probe = headBranchProbe(provider, headContext.headSelectors, "all"); + for (const headSelector of probe.headSelectors) { const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "all", - limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20, + limit: probe.limit, }); for (const pr of pullRequests.map(toPullRequestInfo)) { @@ -2129,8 +2115,10 @@ export const make = Effect.gen(function* () { const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); const policy = yield* resolveStylePolicy(cwd, settings); const changeRequestTemplate = - settings.style.followChangeRequestTemplates && provider.kind === "github" - ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) + settings.style.followChangeRequestTemplates && provider.readChangeRequestTemplate + ? Option.getOrUndefined( + yield* provider.readChangeRequestTemplate({ cwd, treeish: baseRangeRef }), + ) : undefined; const generated = yield* textGeneration.generatePrContent({ diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index a6a3e56564dc..aab68c7b1877 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ThreadId } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { HTML_RENDER_MEASURE_FONTS, HTML_RENDER_MEASURE_WIDTHS, @@ -238,16 +238,18 @@ describe("HtmlRender", () => { "screenshots the page in headless Chrome with the requested theme and every console level", (ctx) => Effect.gen(function* () { - const executable = (yield* HostProcessEnvironment)[TEST_BROWSER_ENV]; + const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); yield* Effect.gen(function* () { const htmlRender = yield* HtmlRender.HtmlRender; const preview = yield* htmlRender.preview({ html: [ - '
', + '
', '', "", @@ -271,6 +273,7 @@ describe("HtmlRender", () => { expect(preview.consoleMessages).toEqual( expect.arrayContaining([ { level: "log", text: "ready 3" }, + { level: "log", text: "focus 2px -2px 400 400" }, { level: "info", text: HTML_RENDER_MEASURE_FONTS.sans }, { level: "warning", @@ -292,7 +295,7 @@ describe("HtmlRender", () => { "keeps every local file but the page itself out of the browser", (ctx) => Effect.gen(function* () { - const executable = (yield* HostProcessEnvironment)[TEST_BROWSER_ENV]; + const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); yield* Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -323,7 +326,7 @@ describe("HtmlRender", () => { "keeps the page off this machine's local network", (ctx) => Effect.gen(function* () { - const executable = (yield* HostProcessEnvironment)[TEST_BROWSER_ENV]; + const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); // The page's connections go through a proxy that only reaches public // addresses, and WebRTC is gone. Each line below is a way out that @@ -381,7 +384,7 @@ describe("HtmlRender", () => { "measures a published page at every client width with a fresh load each", (ctx) => Effect.gen(function* () { - const executable = (yield* HostProcessEnvironment)[TEST_BROWSER_ENV]; + const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); yield* Effect.gen(function* () { const htmlRender = yield* HtmlRender.HtmlRender; diff --git a/apps/server/src/htmlRender/HtmlRender.ts b/apps/server/src/htmlRender/HtmlRender.ts index cfbf6c206ffb..47c7238fb476 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -1,5 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { clampHtmlRenderHeight, HTML_RENDER_COLUMN_WIDTH, @@ -341,7 +341,7 @@ const make = Effect.gen(function* () { const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS); // Chrome's sandbox stays on unless the operator explicitly turns it off. // Chrome also refuses it as root, where that opt-out is the only way to run. - const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcessEnvironment); + const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); /** Runs one browser launch; a host that cannot start it gets setup steps instead. */ diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 8d469248857e..35cfdf706416 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -64,7 +64,7 @@ describe("untraced requests", () => { expect(spanNames).toEqual([]); yield* client.get("/api/environment"); - expect(spanNames).toContain("http.server GET"); + expect(spanNames).toEqual(["GET"]); }).pipe( Effect.scoped, Effect.provideService( diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index f6c3cb9e8843..0ea44fb3ca07 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import { KeybindingsConfigError, MAX_KEYBINDINGS_COUNT } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const encodeKeybindingsConfigJson = Schema.encodeEffect(KeybindingsConfigJson); @@ -564,7 +564,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); // chmod cannot make a directory unwritable on Windows, so the write succeeds. - it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "fails when config directory is not writable", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index a77e8f600252..e1a688ff64f4 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -197,6 +197,9 @@ it.effect("rejects unavailable agent access before booting or opening a device", Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provide(layerUnavailable), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" }), + ), Layer.provide(NodeServices.layer), ), ), diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index fb9ca151d360..330fc1840de6 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -17,8 +17,14 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; -import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/http"; +import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; +import { + HttpBody, + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServerResponse, +} from "effect/http"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerConfig from "../config.ts"; @@ -746,17 +752,38 @@ it.effect("sheds log entries before locators when every list is full", () => it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { - const layerServer = McpServer.layerHttp({ - name: "MCP termination test", - version: "1.0.0", - path: "/mcp", - protocols: [McpProtocol.v2025_06_18], - }); + const token = "providerTokenWithoutDots"; + const scope: McpInvocationContext.McpInvocationScope = { + environmentId, + requestNamespace: "provider-session", + thread: { + threadId: ThreadId.make("thread-provider"), + providerSessionId: "provider-session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const layerServer = McpHttpServer.layerMcpTransport.pipe( + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (presented) => + Effect.succeed( + presented === token + ? (scope as McpInvocationContext.McpThreadInvocationScope) + : undefined, + ), + }), + ), + ); yield* HttpRouter.serve(layerServer, { disableListenLog: true, disableLogger: true, }).pipe(Layer.build); - const httpClient = yield* HttpClient.HttpClient; + const httpClient = (yield* HttpClient.HttpClient).pipe( + HttpClient.mapRequest(HttpClientRequest.bearerToken(token)), + ); const initializeResponse = yield* httpClient.post("/mcp", { headers: { accept: "application/json, text/event-stream" }, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f4da6e051a7c..0e2b52696712 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -19,6 +19,7 @@ import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as DeviceService from "../device/DeviceService.ts"; import * as HtmlRender from "../htmlRender/HtmlRender.ts"; +import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpToolAccess from "./McpToolAccess.ts"; @@ -762,11 +763,25 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct /** * `McpServer.toolkit` for handlers that declared their access (see * `McpToolAccess`). Every toolkit on `/mcp` registers through this. + * + * `McpServer.toolkit` asks for every service its tools declare when it + * registers them, but the auth middleware provides `McpInvocationContext` to + * each request instead. Registration must not get one: the services it + * captures would replace the request's. */ export const toolkitRegistration = , EX, RX>( toolkit: Toolkit.Toolkit, handlers: McpToolAccess.HandlersLayer, -) => McpServer.toolkit(toolkit).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); +) => { + const registration = McpServer.toolkit(toolkit); + // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off - the auth middleware provides it per request. + const registered = registration as Layer.Layer< + never, + never, + Exclude, McpInvocationContext.McpInvocationContext> + >; + return registered.pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); +}; /** A hand-registered tool, also only with handlers that declared their access. */ const imageToolRegistration = , A, E, R, EX, RX>( @@ -811,10 +826,10 @@ const layerPreviewControlsRegistration = toolkitRegistration( PreviewControlsHandlers.layer, ); -const layerEnvironmentRegistration = toolkitRegistration( +export const layerEnvironmentToolkit = toolkitRegistration( EnvironmentToolkit, EnvironmentHandlers.layer, -); +).pipe(Layer.provide(ThreadCommandExecutor.layer)); const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); @@ -848,6 +863,7 @@ export const layerMcpTransport = McpServer.layerHttp({ version: packageJson.version, path: "/mcp", protocols: [McpProtocol.v2025_06_18], + allowSessionTermination: true, }).pipe(Layer.provide(layerMcpAuthMiddleware)); export const layer = Layer.mergeAll( @@ -856,7 +872,7 @@ export const layer = Layer.mergeAll( layerThreadToolkit, layerAttachmentToolkit, layerProjectRegistration, - layerEnvironmentRegistration, + layerEnvironmentToolkit, layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index d232386dec88..8f709ecf8625 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -57,6 +57,7 @@ import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.t import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -680,6 +681,7 @@ describe("orchestrator MCP toolkit", () => { Layer.provide(layerRegistry), Layer.provide(layerProviderRegistry), Layer.provide(layerScheduledTaskStub), + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide( Layer.mock(ProjectService.ProjectService)({ getById: (id) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 9380a8d7af90..a938ba7fd999 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -31,6 +31,7 @@ import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerConfig from "../../config.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; import * as ProjectService from "../../project/ProjectService.ts"; import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; @@ -69,6 +70,12 @@ import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); +// Registration asks for every service the thread tools declare; these cases call none that use them. +const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), +); + it("publishes unique tool names with reference-free object-root inputs", () => { const names = new Set(); for (const toolkit of [ @@ -152,7 +159,7 @@ it.effect("checks capability through the production registration", () => expect(result.structuredContent).toBeUndefined(); }).pipe( Effect.provide( - McpHttpServer.layerThreadToolkit.pipe( + layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide(McpToolAccessTestkit.liveThreadsLayer), @@ -193,7 +200,7 @@ it.effect("returns a bounded public failure without serializing storage causes", expect(validate({ sequence: "invalid" }).valid).toBe(false); }).pipe( Effect.provide( - McpHttpServer.layerThreadToolkit.pipe( + layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide( @@ -308,7 +315,7 @@ it.effect("returns invalid parameter errors through the production registration" expect(error._tag).toBe("InvalidParams"); }).pipe( Effect.provide( - McpHttpServer.layerThreadToolkit.pipe( + layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), @@ -333,7 +340,7 @@ it.effect("keeps unexpected handler defects private through the production regis ]); }).pipe( Effect.provide( - McpHttpServer.layerThreadToolkit.pipe( + layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide( @@ -451,7 +458,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act expect(declaredFailure(forked)).toMatchObject({ code: "target_required" }); }).pipe( Effect.provide( - McpHttpServer.layerThreadToolkit.pipe( + layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide( @@ -511,7 +518,7 @@ it.effect("a read-only client reads threads and is refused every write before it expect(dispatched).toEqual([]); }).pipe( Effect.provide( - McpHttpServer.layerThreadToolkit.pipe( + layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide( diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 1543bbc87100..669d90f23ac5 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -9,7 +9,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../../../config.ts"; import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; @@ -179,7 +179,7 @@ const handlers = { const targetArgs = [...agentDeviceTargetArgs(device), ...agentArgs]; const config = yield* ServerConfig.ServerConfig; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const shimDir = yield* ensureAgentDeviceShim({ entryPath: yield* devices.agentCli, stateDir: config.stateDir, diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts index e4ae660cc004..bf32658886a0 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -12,11 +12,13 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import { McpSchema, McpServer } from "effect/ai"; import * as Environment from "../../../environment/ServerEnvironment.ts"; import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; import * as Settings from "../../../serverSettings.ts"; +import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; @@ -25,6 +27,71 @@ import { EnvironmentToolkit } from "./tools.ts"; const environmentId = EnvironmentId.make("environment:preferences"); const threadId = ThreadId.make("thread:preferences"); +const caller: McpInvocationContext.McpInvocationScope = { + environmentId, + requestNamespace: "provider:preferences", + thread: { + threadId, + providerSessionId: "provider:preferences", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration" as const]), + issuedAt: 0, +}; +const layerEnvironment = Layer.mock(Environment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId, + label: "Test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), +}); + +it.effect("updates preferences for a thread caller through the /mcp registration", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "t3_environment_preferences_update", + arguments: { newWorktreesStartFromOrigin: true }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, caller), + Effect.provideService( + McpSchema.McpServerClient, + McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "preferences", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "preferences", version: "1" }, + }, + getClient: Effect.die("unused"), + }), + ), + ); + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ newWorktreesStartFromOrigin: true }); + }).pipe( + Effect.provide( + McpHttpServer.layerEnvironmentToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(liveThreadShell(id)), + }), + ), + Layer.provide(layerEnvironment), + Layer.provide(Settings.layerTest()), + ), + ), + ), +); it.effect("refuses a preferences update when the caller's turn ends while it waits", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 588d11d9c715..91257c2fdb80 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -22,6 +22,13 @@ import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskServ import * as SecretRequests from "../../../secrets/SecretRequests.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; +import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts"; +import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; +import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; +import * as PreviewManager from "../../../preview/Manager.ts"; +import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -41,6 +48,13 @@ const layerStubServices = Layer.mergeAll( Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), + Layer.mock(PreviewManager.PreviewManager)({}), + Layer.mock(ServerSecretStore.ServerSecretStore)({}), + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadSearch.ThreadSearch)({}), ); const ToolsListPayload = Schema.fromJsonString( diff --git a/apps/server/src/observability/HeapSnapshot.ts b/apps/server/src/observability/HeapSnapshot.ts index 827dfab2c6d6..6359c1628c58 100644 --- a/apps/server/src/observability/HeapSnapshot.ts +++ b/apps/server/src/observability/HeapSnapshot.ts @@ -2,7 +2,7 @@ import * as NodePath from "node:path"; import * as NodeV8 from "node:v8"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -40,7 +40,7 @@ export const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true } */ export const layer = Layer.effectDiscard( Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; + if ((yield* HostProcess.Platform) === "win32") return; const { logsDir } = yield* ServerConfig.ServerConfig; const runFork = Effect.runForkWith(yield* Effect.context()); const onSignal = () => void runFork(writeHeapSnapshot(logsDir)); diff --git a/apps/server/src/observability/RpcInstrumentation.test.ts b/apps/server/src/observability/RpcInstrumentation.test.ts index 4ac5c52ee031..75dc6138bbdf 100644 --- a/apps/server/src/observability/RpcInstrumentation.test.ts +++ b/apps/server/src/observability/RpcInstrumentation.test.ts @@ -72,7 +72,7 @@ const rpcSpans = (ended: ReadonlyArray) => // RpcTest keeps Effect's own RpcServer/RpcClient spans, which ws.ts turns off with // `disableTracing: true`. Everything else comes from the middleware or the handlers. const appSpans = (ended: ReadonlyArray) => - ended.filter((span) => !/^Rpc(Server|Client)\./.test(span.name)); + ended.filter((span) => span.attributes.get("rpc.system.name") !== "effect_rpc"); const exitTag = (span: Tracer.NativeSpan | undefined) => span?.status._tag === "Ended" ? span.status.exit._tag : undefined; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 0802fef78f9e..8651890a2052 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -27,7 +27,7 @@ import { type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; import { GrokSettings } from "@t3tools/provider-grok/settings"; -import { HostProcessIsExecutable, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -705,7 +705,7 @@ describe("AcpAdapterV2", () => { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation().pipe( - Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcess.IsExecutable, true), ); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1833,7 +1833,7 @@ describe("AcpAdapterV2", () => { it.live("cleans detached fixtures when an assertion aborts the test scope", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; const fileSystem = yield* FileSystem.FileSystem; let published: ReadonlyArray = []; const failed = yield* Effect.scoped( @@ -1947,7 +1947,7 @@ describe("AcpAdapterV2", () => { it.live("reaps detached native work when the provider exits before explicit teardown", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2026,7 +2026,7 @@ describe("AcpAdapterV2", () => { it.live("surfaces reduced guarantee when delegated cgroup containment is unavailable", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation(); @@ -3100,7 +3100,7 @@ describe("AcpAdapterV2", () => { instanceId, settings: DEFAULT_GROK_SETTINGS, environment: {}, - hostPlatform: yield* HostProcessPlatform, + hostPlatform: yield* HostProcess.Platform, selfInvocation: yield* resolveSelfInvocation(), // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => @@ -3147,7 +3147,7 @@ describe("AcpAdapterV2", () => { instanceId, settings: DEFAULT_GROK_SETTINGS, environment: {}, - hostPlatform: yield* HostProcessPlatform, + hostPlatform: yield* HostProcess.Platform, selfInvocation: yield* resolveSelfInvocation(), // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => @@ -13147,7 +13147,7 @@ describe("AcpAdapterV2", () => { it.live("direct Stop skips uninterruptible ACP cancel and recovers after native teardown", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -13567,7 +13567,7 @@ describe("AcpAdapterV2", () => { it.live("restart_active terminates native work and reloads a clean runtime", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9c4e336815f7..6777f0a2902a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -47,7 +47,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { Tool } from "effect/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; @@ -1171,7 +1171,7 @@ describe("ClaudeAdapterV2 executable path", () => { const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; const executablePaths = yield* captureSdkExecutablePaths("claude").pipe( - Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcess.Platform, "win32"), Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index cc8a5bef6f5a..730ed8a575f2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -8,7 +8,7 @@ import { type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { ProviderReplayEntry, @@ -1440,7 +1440,7 @@ async function recordMessagesUntilToolUse(input: { const resolveClaudeRecordingExecutablePath = Effect.fn("resolveClaudeRecordingExecutablePath")( function* (environment: NodeJS.ProcessEnv) { const resolveExecutable = yield* SpawnExecutableResolution; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const resolved = resolveExecutable("claude", platform, environment); if (resolved === undefined) { return undefined; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c7b1fb0d5819..79d7bdd7e115 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -31,7 +31,7 @@ import type { WebSearchOutput, } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, @@ -8176,17 +8176,17 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( ) { const { instanceId, environment, enabled, config } = input; const fileSystem = yield* FileSystem.FileSystem; - const hostEnvironment = yield* HostProcessEnvironment; + const hostEnvironment = yield* HostProcess.Environment; const idAllocator = yield* IdAllocator.IdAllocatorV2; const queryRunner = yield* ClaudeAgentSdkQueryRunner; const serverConfig = yield* ServerConfig.ServerConfig; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); + const baseEnvironment = yield* mergeProviderInstanceEnvironment(environment, hostEnvironment); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const binaryPath = yield* resolveClaudeSdkExecutablePath( - expandHomePath(config.binaryPath), + expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), claudeEnvironment, ); return yield* makeClaudeAdapterV2({ @@ -8231,7 +8231,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const hostEnvironment = yield* HostProcessEnvironment; + const hostEnvironment = yield* HostProcess.Environment; const idAllocator = yield* IdAllocator.IdAllocatorV2; const queryRunner = yield* ClaudeAgentSdkQueryRunner; const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 5b18f436c26e..9a1c8f6c900f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -29,7 +29,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexError from "effect-codex-app-server/errors"; @@ -698,8 +698,8 @@ describe("CodexAdapterV2 process spawning", () => { assert.deepEqual(command.options.env, { CUSTOM: "1" }); assert.equal(command.options.extendEnv, true); }).pipe( - Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, { PATH: "C:\\Windows\\System32", HOST_ONLY: "1", }), @@ -726,7 +726,7 @@ describe("CodexAdapterV2 process spawning", () => { assert.deepEqual(command.args, ["app-server"]); assert.equal(command.options.shell, false); }).pipe( - Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcess.Platform, "win32"), Effect.provideService(SpawnExecutableResolution, () => "C:\\bin\\codex.exe"), ), ); @@ -774,7 +774,7 @@ describe("CodexAdapterV2 process spawning", () => { ["app-server", "--strict-config", "-c", "model_reasoning_summary=detailed"], ["app-server", "--enable", "env-feature"], ]); - }).pipe(Effect.provideService(HostProcessPlatform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); it.effect("expands ~ in the configured binary path before spawning", () => @@ -821,7 +821,7 @@ describe("CodexAdapterV2 process spawning", () => { Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), ), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index dbdbab32a74d..87c116250c5b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -30,7 +30,7 @@ import { type ProviderSetupError, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -1562,7 +1562,7 @@ export const createCodexAdapterV2 = ( const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; - const hostEnvironment = yield* HostProcessEnvironment; + const hostEnvironment = yield* HostProcess.Environment; const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig; const homeLayout = yield* resolveCodexHomeLayout(config); @@ -1582,14 +1582,14 @@ export const createCodexAdapterV2 = ( const settings = { ...config, enabled, - binaryPath: expandHomePath(config.binaryPath), + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; return yield* makeCodexAdapterV2({ instanceId, settings, - environment: mergeProviderInstanceEnvironment(environment, hostEnvironment), + environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), clientFactory, crypto, fileSystem, @@ -1623,7 +1623,7 @@ const layer: Layer.Layer< const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; - const hostEnvironment = yield* HostProcessEnvironment; + const hostEnvironment = yield* HostProcess.Environment; const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig; diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index daf906ec92cc..4b6b68861c51 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -1,4 +1,4 @@ -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import type { InteractionUpdate, RunResult } from "@cursor/sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -519,7 +519,7 @@ export function layer( // Skill discovery also scans user roots under HOME; an empty HOME keeps // replays from picking up the host's own skills. const layerHostEnvironment = Layer.effect( - HostProcessEnvironment, + HostProcess.Environment, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-replay-home-" }); diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 732bc6904f40..1a1391ca9363 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -7,7 +7,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { GrokSettings } from "@t3tools/provider-grok/settings"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as EffectAcpErrors from "effect-acp/errors"; import { xAiRateLimitedErrorCode } from "@t3tools/provider-grok/testing"; @@ -94,9 +94,12 @@ describe("acpSubagentStatusBlocksTurnSettlement", () => { describe("GrokAdapterV2 capabilities", () => { it("preserves Grok's rate-limit stop and distinguishes other prompt failures", () => { - const flavor = makeGrokAcpAdapterFlavor({ - makeRuntime: () => Effect.never, - } as unknown as Parameters[0]); + const flavor = makeGrokAcpAdapterFlavor( + { + makeRuntime: () => Effect.never, + } as unknown as Parameters[0], + "/home/ada", + ); const limit = flavor.promptFailure?.( new EffectAcpErrors.AcpRequestError({ code: xAiRateLimitedErrorCode, @@ -122,9 +125,12 @@ describe("GrokAdapterV2 capabilities", () => { }); it("wires hard Stop teardown but soft non-Stop interrupts in the constructor flavor", () => { - const flavor = makeGrokAcpAdapterFlavor({ - makeRuntime: () => Effect.never, - } as unknown as Parameters[0]); + const flavor = makeGrokAcpAdapterFlavor( + { + makeRuntime: () => Effect.never, + } as unknown as Parameters[0], + "/home/ada", + ); assert.isFalse(flavor.interruptPromptOnCancel); // User Stop (requestRuntimeRestart) keeps the hard process-group kill and @@ -139,9 +145,12 @@ describe("GrokAdapterV2 capabilities", () => { }); it("terminalizes only foreground tools under the actual Grok flavor", () => { - const flavor = makeGrokAcpAdapterFlavor({ - makeRuntime: () => Effect.never, - } as unknown as Parameters[0]); + const flavor = makeGrokAcpAdapterFlavor( + { + makeRuntime: () => Effect.never, + } as unknown as Parameters[0], + "/home/ada", + ); const foreground = { toolCallId: "foreground-1", title: "Terminal", @@ -284,9 +293,12 @@ describe("ACP permission policy", () => { }); describe("Grok permission prompts", () => { - const disposition = makeGrokAcpAdapterFlavor({ - makeRuntime: () => Effect.never, - } as unknown as Parameters[0]).permissionDisposition; + const disposition = makeGrokAcpAdapterFlavor( + { + makeRuntime: () => Effect.never, + } as unknown as Parameters[0], + "/home/ada", + ).permissionDisposition; // grok_auto_blocked_command replays Auto end to end. When an explicit policy // launches Grok asking instead, T3's policy still answers its prompts. @@ -352,7 +364,7 @@ describe("Grok launch permission mode", () => { return launches; }).pipe( // Keep the launch argv unwrapped by the Linux cgroup shim. - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Platform, "darwin"), Effect.provide(layerTest), ); diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index b6b67c7a0d56..8d3e86d1ae2a 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; @@ -43,7 +43,7 @@ function layerGrokProviderAdapterRegistryReplay( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; + const hostPlatform = yield* HostProcess.Platform; // Same queue the continuation worker drains when the fixture runs it. const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const replayGate = options.replayGate; diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index 9f4549370377..9a1e43a8b44b 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -2354,14 +2354,15 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { it.effect("does not emit a SQL span for an empty safety claim", () => Effect.gen(function* () { const outbox = yield* EffectOutbox.EffectOutboxV2; - const spans: Array = []; + const statements: Array = []; const tracer = Tracer.make({ span: (options) => { const span = new Tracer.NativeSpan(options); const end = span.end.bind(span); span.end = (endTime, exit) => { end(endTime, exit); - spans.push(span.name); + const query = span.attributes.get("db.query.text"); + if (query !== undefined) statements.push(query); }; return span; }, @@ -2372,7 +2373,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { .pipe(Effect.withTracer(tracer)); assert.isTrue(Option.isNone(claim)); - assert.notInclude(spans, "sql.execute"); + assert.deepEqual(statements, []); }).pipe(Effect.provide(Layer.fresh(layerEffectOutboxProvided))), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 4f870a606e00..977b225f5256 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2831,6 +2831,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, snoozedUntil: null, snoozedAt: null, + lastSnoozeWakeAt: alreadyAwake ? thread.lastSnoozeWakeAt : now, updatedAt: alreadyAwake ? thread.updatedAt : now, }; } @@ -2862,6 +2863,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio settledAt: thread.settledOverride === "settled" ? null : thread.settledAt, snoozedUntil: null, snoozedAt: null, + lastSnoozeWakeAt: thread.snoozedUntil == null ? thread.lastSnoozeWakeAt : now, updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now, }; } @@ -2933,7 +2935,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) === Date.parse(thread.limitRecovery.resetAt) - ? { snoozedUntil: null, snoozedAt: null } + ? { snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: now } : {}), ...(command.branch === undefined ? {} : { branch: command.branch }), ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a6a092ff84b7..8c33faef808b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -204,7 +204,10 @@ export type ProjectionSettlementCandidate = Pick< | "activityRunStatus" | "pendingRuntimeRequest" | "pendingBackgroundTasks" -> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null }; +> & + Pick & { + readonly latestUserAuthoredMessageAt: DateTime.Utc | null; + }; const ProjectionCheckpointContext = Schema.Struct({ runs: Schema.Array( @@ -6035,6 +6038,7 @@ export const layerMemory: Layer.Layer = Layer.effect( const shell = threadShellFromProjection(projection); return { ...shell, + lastSnoozeWakeAt: projection.thread.lastSnoozeWakeAt ?? null, latestUserAuthoredMessageAt: shell.latestUserAuthoredMessageAt ?? null, }; }) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 36f4bdb15891..19e67f949b61 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -31,7 +31,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; -import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as GitManager from "../git/GitManager.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerActivation from "../serverActivation.ts"; diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 5f1fcd8269ac..a966cbd95456 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -29,7 +29,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; -import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 7005534f89ed..5a5f61ae3c4d 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -29,7 +29,7 @@ import type * as Scope from "effect/Scope"; import { type ProviderChangeRequestWatchFingerprint, PullRequestProviderError, -} from "../pullRequest/PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 4b0e451e0ae6..00daff5a8c87 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -276,6 +276,26 @@ describe("resolveAutoSettlementAt", () => { ).toBeNull(); }); + it.each(["manual", "timer"])("restarts inactivity on a %s snooze wake", (wake) => { + const thread = { + ...shell({ latestRunCompletedAt: at(-4 * DAY_MS) }), + ...(wake === "manual" + ? { lastSnoozeWakeAt: at(-1_000) } + : { snoozedAt: at(-3 * DAY_MS), snoozedUntil: at(-1_000) }), + }; + const input = { + thread, + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 1, + autoSettleOnMerge: false, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ ...input, nowMs: NOW_MS + DAY_MS }), + ).toEqual(at(-1_000)); + }); + it("settles on merge only after the user's last action and preserves the activity time", () => { const thread = shell({ latestUserMessageAt: at(-2 * 60 * 60 * 1_000), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 5e3f7e795600..8b55e25cdc39 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -237,8 +237,14 @@ export function resolveAutoSettlementAt(input: { return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); } if (input.autoSettleAfterDays === null || activityAtMs === null) return null; - return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS - ? DateTime.makeUnsafe(activityAtMs) + // A passed wake timer restarts inactivity. An earlier wake can only delay settlement. + const snoozedUntilMs = toMillis(thread.snoozedUntil); + const timerWakeAtMs = + snoozedUntilMs !== null && snoozedUntilMs <= input.nowMs ? snoozedUntilMs : null; + const inactivityAtMs = + latestMillis([activityAtMs, toMillis(thread.lastSnoozeWakeAt), timerWakeAtMs]) ?? activityAtMs; + return inactivityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS + ? DateTime.makeUnsafe(inactivityAtMs) : null; } diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index d805bab92d83..bb70c880b061 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -65,7 +65,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; -import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -4548,6 +4548,24 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { assert.deepEqual(thread.snoozedAt, firstSnoozedAt); assert.deepEqual(thread.updatedAt, firstUpdatedAt); + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("runtime-layer-snoozed-thread-wake"), + threadId, + reason: "user", + }); + const projections = yield* ProjectionStore.ProjectionStoreV2.pipe( + Effect.provide(ProjectionStore.layer), + ); + const [candidate] = yield* projections.getSettlementCandidates(threadId); + assert.deepEqual(candidate?.lastSnoozeWakeAt, yield* DateTime.now); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("runtime-layer-snoozed-thread-snooze-after-wake"), + threadId, + snoozedUntil, + }); + yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.ts index 184abc6147ce..ef2ea88c574a 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.ts @@ -14,16 +14,16 @@ * never trusted beyond these checks. */ import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const SCRIPT_BYTE_CAP = 256 * 1024; -function scriptsRoot(): string { - return NodePath.join(NodeOS.homedir(), ".claude", "projects"); +function scriptsRoot(home: string): string { + return NodePath.join(home, ".claude", "projects"); } export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { @@ -38,8 +38,9 @@ export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")( }); } + const home = yield* HostProcess.HomeDirectory; const root = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(scriptsRoot()), + try: () => NodeFSP.realpath(scriptsRoot(home)), catch: (cause) => new OrchestrationGetWorkflowScriptError({ reason: "root-unavailable", diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c8..38bbcde011cb 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,4 +1,4 @@ -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { listLoginShellCandidates, mergePathEntries, @@ -53,8 +53,8 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< never, FileSystem.FileSystem | Path.Path > { - const platform = yield* HostProcessPlatform; - const env = yield* HostProcessEnvironment; + const platform = yield* HostProcess.Platform; + const env = yield* HostProcess.Environment; if (platform === "win32") { const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( @@ -93,11 +93,12 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; if (input === "~") { - return NodeOS.homedir(); + return home; } if (input.startsWith("~/") || input.startsWith("~\\")) { - return join(NodeOS.homedir(), input.slice(2)); + return join(home, input.slice(2)); } return input; }); @@ -105,7 +106,7 @@ export const expandHomePath = Effect.fn(function* (input: string) { export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { const { join, resolve } = yield* Path.Path; if (!raw || raw.trim().length === 0) { - return join(NodeOS.homedir(), ".t3"); + return join(yield* HostProcess.HomeDirectory, ".t3"); } return resolve(yield* expandHomePath(raw.trim())); }); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index fa4fd4402e93..8a6bf87d4699 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -6,7 +6,7 @@ import { PREVIEW_URL_MAX_LENGTH, type DiscoveredLocalServer, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -74,7 +74,7 @@ const layerProbeFailure = ( reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }), - Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))), ), ), @@ -86,7 +86,7 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( layerNoProc, layerTestProcessRunner, layerTestIntegrationNet, - Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcess.Platform, "win32"), FetchHttpClient.layer, ), ), @@ -122,7 +122,7 @@ const layerLsofScanner = (input: { reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }), - Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), ), diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index eb573def97e0..9b1d9e966333 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -25,7 +25,7 @@ import { ThreadId, type DiscoveredLocalServer, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Net from "@t3tools/shared/Net"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; @@ -342,7 +342,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; - const hostPlatform = yield* HostProcessPlatform; + const hostPlatform = yield* HostProcess.Platform; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ listeners: new Map(), diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index 6e984c2301e8..32c46fe24ab1 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Crypto from "effect/Crypto"; import type * as Duration from "effect/Duration"; @@ -20,7 +20,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts"; // POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite. const hostPlatform: NodeJS.Platform = - HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux"; + HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux"; const executableName = hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell"; const ROOT = "chrome-headless-shell-fixture/"; @@ -105,8 +105,8 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( }, ...(options.wait === undefined ? {} : { wait: options.wait }), }).pipe( - Effect.provideService(HostProcessPlatform, hostPlatform), - Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcess.Platform, hostPlatform), + Effect.provideService(HostProcess.Architecture, "x64"), Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index 8b64861ecbbb..1525bcde44cf 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest. import * as EffectNodeStream from "@effect/platform-node/NodeStream"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -175,8 +175,8 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( const http = yield* HttpClient.HttpClient; // Installs belong to the service, so they finish even when no caller is still waiting. const serviceScope = yield* Effect.scope; - const platform = yield* HostProcessPlatform; - const arch = yield* HostProcessArchitecture; + const platform = yield* HostProcess.Platform; + const arch = yield* HostProcess.Architecture; const release = options.release === undefined ? previewBrowserRelease(platform, arch) : options.release; const wait = options.wait ?? "45 seconds"; diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts index 8fcfddff5cb5..5ae0a70752ca 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -67,7 +67,7 @@ const diagnose = (input: { setupCommand: "sudo t3 browser setup", }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(HostProcessPlatform, input.platform), + Effect.provideService(HostProcess.Platform, input.platform), Effect.map((error) => ({ error, commands })), ); }; diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts index 9ea56c4fa18b..de136cf41dad 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -7,7 +7,7 @@ * gets the command instead, and only the operator's explicit * `T3CODE_SERVER_BROWSER_SANDBOX=0` launches without it. */ -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; @@ -108,7 +108,7 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc if (input.output.includes(NO_SANDBOX_SIGNATURE)) { return new PreviewBrowserSandboxError({ setupCommand }); } - if ((yield* HostProcessPlatform) !== "linux") return undefined; + if ((yield* HostProcess.Platform) !== "linux") return undefined; const libraries = yield* missingLibraries(input.executable); return libraries.length === 0 ? undefined @@ -147,7 +147,7 @@ export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries") * root, so the server checks it at startup. */ export const sandboxBlocked = Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return false; + if ((yield* HostProcess.Platform) !== "linux") return false; const fs = yield* FileSystem.FileSystem; const restricted = yield* fs.readFileString(USERNS_RESTRICTION).pipe( Effect.map((value) => value.trim() === "1"), diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index f3d9bea8b40f..072a022f90c0 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -42,11 +42,7 @@ import { SERVER_BROWSER_AUTOMATION_CLIENT_ID, type PreviewAppearancePreference, } from "@t3tools/contracts"; -import { - HostProcessArchitecture, - HostProcessEnvironment, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; import * as NodeCrypto from "node:crypto"; @@ -476,7 +472,7 @@ const CLIPBOARD_SCRIPT = `(() => { const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const host = { platform: yield* HostProcessPlatform, arch: yield* HostProcessArchitecture }; + const host = { platform: yield* HostProcess.Platform, arch: yield* HostProcess.Architecture }; const manager = yield* PreviewManager.PreviewManager; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const environment = yield* ServerEnvironment.ServerEnvironment; @@ -2367,7 +2363,7 @@ const make = Effect.gen(function* () { yield* manager.events.pipe(Stream.runForEach(mirrorManagerEvent), Effect.forkScoped); // Whoever runs the server learns the fix before anyone opens a tab. if ( - !PreviewBrowserHost.sandboxDisabled(yield* HostProcessEnvironment) && + !PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment) && (yield* PreviewBrowserHost.sandboxBlocked) ) { yield* Effect.logWarning( diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index cd83cad6e968..6df4ea16db88 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -17,7 +17,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; @@ -25,7 +25,7 @@ import * as ExternalLauncher from "./externalLauncher.ts"; // directory to a posix-mocked resolver as PATH. On a Windows host the temp // path carries a drive letter, so the posix `:` split shatters it; there is // no posix executable to find there anyway. -const windowsHost = HostProcessPlatform.defaultValue() === "win32"; +const windowsHost = HostProcess.Platform.defaultValue() === "win32"; interface MockSpawnResult { readonly exitCode?: number; @@ -85,7 +85,7 @@ const layerTest = (input: { return Layer.mergeAll( ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, layerSpawner))), - Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed(HostProcess.Platform, input.platform), Layer.succeed( SpawnExecutableResolution, (command) => input.resolveExecutable?.(command) ?? command, @@ -1302,7 +1302,7 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = Effect.provide( Layer.mergeAll( layerLauncher, - Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcess.Platform, "win32"), ConfigProvider.layer( ConfigProvider.fromEnv({ env: { @@ -1367,7 +1367,7 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan", Effect.provide( Layer.mergeAll( layerLauncher, - Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcess.Platform, "win32"), ConfigProvider.layer( ConfigProvider.fromEnv({ env: { diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 6215f8653ffc..74e95b9ddcc4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -19,7 +19,7 @@ import { type LaunchEditorInput, } from "@t3tools/contracts"; import { resolveEditorCommand } from "@t3tools/shared/editor"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { isCommandAvailable, resolveSpawnCommand, @@ -441,20 +441,20 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( target: string, ) { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const env = yield* readBrowserLaunchEnv; return buildBrowserLaunch(target, platform, env); }); const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* buildAvailableEditors(platform, env).pipe(withPathDirectoryListings); }); const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* fileManagerRevealKindForPlatform(platform, env); }, @@ -519,7 +519,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( ExternalLauncherError, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index d241b3b3a4d4..3bec75351baf 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -9,7 +9,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ProcessRunner from "./processRunner.ts"; @@ -145,8 +145,8 @@ describe("runProcess", () => { args: ["repos", "pr", "list", "--source-branch", "feature & release"], env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" }, }).pipe( - Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, { PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", PATHEXT: ".COM;.EXE;.BAT;.CMD", }), @@ -407,7 +407,7 @@ describe("isWindowsCommandNotFound", () => { const isCommandNotFound = yield* ProcessRunner.isWindowsCommandNotFound( 1, "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", - ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + ).pipe(Effect.provideService(HostProcess.Platform, "win32")); expect(isCommandNotFound).toBe(true); }), ); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 0fe74152f1c3..454971a75ab8 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -44,7 +44,7 @@ import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl, parseOriginUrlFromGitConfig, } from "@t3tools/shared/git"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as ServerConfig from "../config.ts"; @@ -629,9 +629,9 @@ export const make = Effect.gen(function* () { const worktreesDir = path.resolve(serverConfig.worktreesDir); // Windows filesystems are case-insensitive, so path prefix checks there // must case fold. - const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; - const hostEnvironment = yield* HostProcessEnvironment; - const homeDir = NodeOS.homedir(); + const foldWorktreeCase = (yield* HostProcess.Platform) === "win32"; + const hostEnvironment = yield* HostProcess.Environment; + const homeDir = yield* HostProcess.HomeDirectory; // `/private/tmp` is what macOS reports for sessions started in `/tmp`. const excludedProjectRoots = new Set( [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => @@ -912,13 +912,13 @@ export const make = Effect.gen(function* () { const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { const configured = homePath.trim(); if (configured.length > 0) { - return path.resolve(expandHomePath(configured)); + return path.resolve(expandHomePath(configured, homeDir)); } const fromEnvironment = environmentHome?.trim() ?? ""; if (fromEnvironment.length > 0) { - return path.resolve(expandHomePath(fromEnvironment)); + return path.resolve(expandHomePath(fromEnvironment, homeDir)); } - return path.join(NodeOS.homedir(), ".claude"); + return path.join(homeDir, ".claude"); }; const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( @@ -1217,7 +1217,7 @@ export const make = Effect.gen(function* () { const gitIdentities = new Map(); for (const candidate of raw) { - const expanded = expandHomePath(candidate.cwd.trim()); + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); if (isExcludedProjectPath(resolved)) continue; @@ -1281,7 +1281,7 @@ export const make = Effect.gen(function* () { ); const importedProjectsByRoot = new Map(); for (const project of importedProjects) { - const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); + const projectRoot = path.resolve(expandHomePath(project.workspaceRoot, homeDir)); importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); } @@ -1328,7 +1328,7 @@ export const make = Effect.gen(function* () { workspaceRoot: string, completedSources: ReadonlyArray, ) { - const root = path.resolve(expandHomePath(workspaceRoot)); + const root = path.resolve(expandHomePath(workspaceRoot, homeDir)); const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; const rootIdentity = yield* directoryIdentity(root); @@ -1343,7 +1343,7 @@ export const make = Effect.gen(function* () { readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; }> = []; for (const candidate of candidates) { - const expanded = expandHomePath(candidate.cwd.trim()); + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; @@ -1439,7 +1439,7 @@ export const make = Effect.gen(function* () { if (snapshotCwd === null) { return Option.some({ _tag: "Skipped" }); } - const expandedCwd = expandHomePath(snapshotCwd.trim()); + const expandedCwd = expandHomePath(snapshotCwd.trim(), homeDir); if ( !path.isAbsolute(expandedCwd) || (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 973617156277..c96f88087865 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,5 @@ import { ProjectId, type ProjectScript } from "@t3tools/contracts"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { projectScriptRuntimeEnv, resolveProjectScripts, @@ -206,8 +206,8 @@ export const make = Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; const completionShell = resolveCompletionShell( - yield* HostProcessPlatform, - yield* HostProcessEnvironment, + yield* HostProcess.Platform, + yield* HostProcess.Environment, ); /** diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index ac256ef3dcd0..e99c2f684954 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -1,11 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { - HostProcessArchitecture, - HostProcessEnvironment, - HostProcessIsExecutable, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -62,7 +57,7 @@ const zipFixtures = { // the host and let the fixture names follow; the suite is about install // mechanics, which are the same on every platform. const hostPlatform: NodeJS.Platform = - HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux"; + HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux"; const completeArchive = Buffer.from( hostPlatform === "win32" ? zipFixtures.windows : zipFixtures.complete, "base64", @@ -189,9 +184,9 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* ( }), }).pipe( Effect.provideService(FileSystem.FileSystem, trackedFs), - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HostProcessArchitecture, "x64"), - Effect.provideService(HostProcessEnvironment, { PATH: options.path ?? "" }), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Architecture, "x64"), + Effect.provideService(HostProcess.Environment, { PATH: options.path ?? "" }), Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => @@ -259,7 +254,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }); expect(requests).toEqual([]); expect(validations).toEqual([]); - }).pipe(Effect.provideService(HostProcessIsExecutable, true)), + }).pipe(Effect.provideService(HostProcess.IsExecutable, true)), ); it.effect("verifies both files before activating a streamed download", () => @@ -734,7 +729,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { // Real posix executables in a real temp dir, resolved by a linux-mocked // PATH walk; a Windows temp path cannot be split on `:`. - it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "honors explicit paths and reports invalid overrides without falling back", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 4f78ee8ebb33..c57200f4afda 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -1,11 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or free-space query. import * as EffectNodeStream from "@effect/platform-node/NodeStream"; import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; -import { - HostProcessArchitecture, - HostProcessEnvironment, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; @@ -156,9 +152,9 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma const http = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serviceScope = yield* Effect.scope; - const platform = yield* HostProcessPlatform; - const arch = yield* HostProcessArchitecture; - const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcess.Platform; + const arch = yield* HostProcess.Architecture; + const environment = yield* HostProcess.Environment; const releaseAsset = options.releaseAsset === undefined ? resolveAntigravityReleaseAsset(platform, arch) @@ -410,7 +406,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma yield* resolveNodeExecutable("Antigravity", environment).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), - Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcess.Platform, platform), Effect.mapError((cause) => installationError("verify", nodeRuntimeUnavailableMessage("Antigravity"), cause), ), diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index 88bc67690125..abc67ad55ea1 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -1,11 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ModelManifest from "./ModelManifest.ts"; import { expect, it } from "@effect/vitest"; -import { - HostProcessArchitecture, - HostProcessEnvironment, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -66,9 +62,9 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, }), - Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService(HostProcessArchitecture, "arm64"), - Effect.provideService(HostProcessEnvironment, { PATH: input.local ? localDirectory : "" }), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Architecture, "arm64"), + Effect.provideService(HostProcess.Environment, { PATH: input.local ? localDirectory : "" }), Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index 7dfce2bba433..fa7876b1e92a 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -1,10 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest. import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; -import { - HostProcessArchitecture, - HostProcessEnvironment, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +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"; @@ -169,9 +165,9 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function const http = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serviceScope = yield* Effect.scope; - const platform = yield* HostProcessPlatform; - const environment = yield* HostProcessEnvironment; - const arch = yield* HostProcessArchitecture; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + const arch = yield* HostProcess.Architecture; const asset = options.releaseAsset === undefined ? resolveCodexReleaseAsset(platform, arch) @@ -311,7 +307,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function args: ReadonlyArray, ) { const resolved = yield* resolveSpawnCommand(command, args).pipe( - Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcess.Platform, platform), ); const child = yield* spawner.spawn( ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell }), @@ -335,7 +331,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function const resolveLocal = Effect.fn("CodexInstallation.resolveLocal")( function* () { const executablePath = yield* resolveCommandPath("codex", { env: environment }).pipe( - Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcess.Platform, platform), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 913a1983a107..cb1a5513cd01 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -47,6 +47,7 @@ import { type CodexRateLimitSnapshot, type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; @@ -374,7 +375,9 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". // Expand here for parity with `CodexTextGeneration`. - const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const resolvedHomePath = input.homePath + ? expandHomePath(input.homePath, yield* HostProcess.HomeDirectory) + : undefined; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = { ...input.environment, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index dc0bdd4d756f..f15cdfeff676 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -5,12 +5,7 @@ import { ProviderInstanceId, type AntigravitySettings, } from "@t3tools/contracts"; -import { - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessIsExecutable, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -37,7 +32,7 @@ import { AntigravityDriver } from "./AntigravityDriver.ts"; import * as ProviderHostLive from "../ProviderHostLive.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -const hostPlatform = HostProcessPlatform.defaultValue(); +const hostPlatform = HostProcess.Platform.defaultValue(); const windowsHost = hostPlatform === "win32"; const decodeRequest = Schema.decodeEffect( Schema.fromJsonString( @@ -65,8 +60,8 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const nodePath = yield* HostProcessExecutablePath; - const baseEnv = yield* HostProcessEnvironment; + const nodePath = yield* HostProcess.ExecutablePath; + const baseEnv = yield* HostProcess.Environment; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-driver-" }); const instanceId = ProviderInstanceId.make(path.basename(root)); const mockAgentPath = yield* path.fromFileUrl( @@ -283,8 +278,8 @@ it.layer(layerTest)("AntigravityDriver", (it) => { expect(h.launches).toEqual([]); }).pipe( Effect.scoped, - Effect.provideService(HostProcessIsExecutable, true), - Effect.provideService(HostProcessEnvironment, { PATH: "" }), + Effect.provideService(HostProcess.IsExecutable, true), + Effect.provideService(HostProcess.Environment, { PATH: "" }), ), ); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index f030341a83c1..c82565e8f2fe 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,7 +1,7 @@ import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import { NodeRuntimeUnavailableError, @@ -26,6 +26,7 @@ import { } from "../../textGeneration/AntigravityTextGeneration.ts"; import { makeAntigravityAuth, type AntigravityAuth } from "../AntigravityAuth.ts"; import * as AntigravityInstallation from "../AntigravityInstallation.ts"; +import * as AntigravityUsage from "./AntigravityUsage.ts"; import { antigravityAuthConfigIssue, antigravityAuthLabel, @@ -80,11 +81,16 @@ export type AntigravityDriverEnv = | ProviderEventLoggers.ProviderEventLoggers; /** Each instance owns its Google profile. Executable releases are shared by the environment. */ -export const AntigravityDriver: ProviderDriver = { +export const AntigravityDriver: ProviderDriver< + AntigravitySettings, + AntigravityDriverEnv, + AntigravityUsage.AntigravityUsage +> = { driverKind: DRIVER, metadata: { displayName: "Antigravity", supportsMultipleInstances: true }, configSchema: AntigravitySettings, defaultConfig: () => decodeSettings({}), + usage: AntigravityUsage.antigravityUsageReader, create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -107,8 +113,12 @@ export const AntigravityDriver: ProviderDriver { it("resolves the home the agent expands ~ against", () => { assert.equal( - resolveAntigravityUserHome("linux", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + resolveAntigravityUserHome( + "linux", + { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }, + "/home/fallback", + ), "/home/user", ); assert.equal( - resolveAntigravityUserHome("win32", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + resolveAntigravityUserHome( + "win32", + { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }, + "/home/fallback", + ), "C:\\Users\\user", ); assert.equal( - resolveAntigravityUserHome("win32", { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }), + resolveAntigravityUserHome( + "win32", + { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }, + "/home/fallback", + ), "D:\\Users\\alice", ); - assert.equal(resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }), "/Users/a b "); - assert.equal(resolveAntigravityUserHome("darwin", { HOME: "" }).length > 0, true); + assert.equal( + resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }, "/home/fallback"), + "/Users/a b ", + ); + assert.equal( + resolveAntigravityUserHome("darwin", { HOME: "" }, "/home/fallback"), + "/home/fallback", + ); }); diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index b11396467582..1bc4e370013f 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import type { ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -18,15 +16,16 @@ import { parse as parseYamlDocument } from "yaml"; export function resolveAntigravityUserHome( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, + home: string, ): string { if (platform === "win32") { if (environment.USERPROFILE) return environment.USERPROFILE; if (environment.HOMEDRIVE && environment.HOMEPATH) { return `${environment.HOMEDRIVE}${environment.HOMEPATH}`; } - return NodeOS.homedir(); + return home; } - return environment.HOME || NodeOS.homedir(); + return environment.HOME || home; } /** diff --git a/apps/server/src/provider/Drivers/AntigravityUsage.ts b/apps/server/src/provider/Drivers/AntigravityUsage.ts new file mode 100644 index 000000000000..ee5369b21716 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravityUsage.ts @@ -0,0 +1,140 @@ +/** + * AntigravityUsage - Antigravity's usage history as a usage source. + * + * Antigravity keeps conversations in SQLite databases under its data + * directories and each instance's profile. Parsed databases stay in memory + * while a database and its WAL keep the same `(size, mtime, ctime)`, so a + * warm scan decodes only what changed. + * + * @module provider/Drivers/AntigravityUsage + */ + +import { type AntigravitySettings, UsageReadError } from "@t3tools/contracts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import type { + ProviderUsageInstance, + ProviderUsageReader, + ProviderUsageScan, +} 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 Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { resolveAntigravityInstanceDirectories } from "../antigravityAuthSupport.ts"; +import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; + +export class AntigravityUsage extends Context.Service< + AntigravityUsage, + { + /** Every Antigravity store: the data directories and each configured instance's profile. */ + readonly scan: (input: { + readonly instances: ReadonlyArray>; + readonly windowStartMs: number; + }) => Effect.Effect, UsageReadError>; + } +>()("t3/provider/Drivers/AntigravityUsage") {} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* ProviderHost.ProviderHost; + const hostEnvironment = yield* HostProcess.Environment; + const cache = makeAntigravityUsageCache(); + + /** `ANTIGRAVITY_DATA_DIR` (comma-separated) or the defaults, canonicalized. */ + const dataRoots = Effect.gen(function* () { + const home = yield* HostProcess.HomeDirectory; + const configured = hostEnvironment["ANTIGRAVITY_DATA_DIR"] + ?.split(",") + .map((value) => value.trim()) + .filter(Boolean); + const defaults = [ + ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => + path.join(home, ".gemini", name), + ), + path.join(home, ".config", "antigravity"), + ]; + const canonical = new Set(); + for (const root of configured?.length ? configured : defaults) { + const resolved = path.resolve(expandHomePath(root, home)); + canonical.add( + yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)), + ); + } + return [...canonical]; + }); + + const scan: AntigravityUsage["Service"]["scan"] = Effect.fn("AntigravityUsage.scan")(function* ({ + instances, + windowStartMs, + }) { + const roots = yield* dataRoots; + // Only configured instances have a profile; the implicit default never ran. + for (const { instanceId } of instances.filter((instance) => instance.configured)) { + const directories = yield* resolveAntigravityInstanceDirectories( + host.paths.stateDir, + instanceId, + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Antigravity profile directory could not be resolved.", + cause, + }), + ), + ); + roots.push(path.join(directories.profile, "antigravity-acp")); + } + const conversationDirs = new Set(); + for (const root of roots) { + const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + const nested = path.join(resolvedRoot, "conversations"); + const dir = (yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false)))) + ? nested + : resolvedRoot; + conversationDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir))); + } + const result = yield* readAntigravityUsage([...conversationDirs], windowStartMs, cache).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const scanned: ProviderUsageScan[] = []; + for (const dir of conversationDirs) { + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + const failed = result.errors.some( + (error) => error === dir || error.startsWith(`${dir}${path.sep}`), + ); + scanned.push({ + dir, + files: !exists && !failed ? null : result.files.filter((file) => file.root === dir), + status: failed ? "partial" : "ok", + ...(failed ? { message: "Some Antigravity history could not be read." } : {}), + }); + } + return scanned; + }); + + return AntigravityUsage.of({ scan }); +}); + +export const layer = Layer.effect(AntigravityUsage, make); + +export const antigravityUsageReader: ProviderUsageReader = { + kind: "scan", + provider: "antigravity", + scan: ({ instances, windowStartMs }) => + AntigravityUsage.pipe(Effect.flatMap((usage) => usage.scan({ instances, windowStartMs }))), +}; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f7935bb8d481..be7872a7df28 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -34,6 +34,7 @@ import { type ClaudeAdapterV2DriverEnv, } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { claudeUsageReader } from "./claudeUsage.ts"; import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; import * as ClaudeResetCredits from "../claudeResetCredits.ts"; import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; @@ -70,6 +71,7 @@ import { makeClaudeContinuationGroupKey, resolveClaudeHomePath, } from "./ClaudeHome.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -107,7 +109,7 @@ export type ClaudeDriverEnv = | ProviderEventLoggers.ProviderEventLoggers | ServerConfig.ServerConfig; -export const ClaudeDriver: ProviderDriver = { +export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { displayName: "Claude", @@ -115,6 +117,7 @@ export const ClaudeDriver: ProviderDriver = { }, configSchema: ClaudeSettings, defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}), + usage: claudeUsageReader, create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -128,7 +131,7 @@ export const ClaudeDriver: ProviderDriver = { const modelCatalog = catalogService .current(DRIVER_KIND) .pipe(Effect.map(resolveClaudeModelCatalog)); - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, @@ -136,7 +139,7 @@ export const ClaudeDriver: ProviderDriver = { const effectiveConfig = { ...config, enabled, - binaryPath: expandHomePath(config.binaryPath), + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), } satisfies ClaudeSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts index 020fc48a4656..b03bd994a14b 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; @@ -17,7 +17,7 @@ function withWindowsResolution(input: { const existing = new Set(input.existingFiles ?? []); return (effect: Effect.Effect) => effect.pipe( - Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcess.Platform, "win32"), Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand), Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)), ); @@ -28,7 +28,7 @@ describe("resolveClaudeSdkExecutablePath", () => { Effect.gen(function* () { expect( yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Platform, "darwin"), Effect.provideService(SpawnExecutableResolution, () => { throw new Error("must not resolve on non-Windows platforms"); }), diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts index fe1192d1df47..a7d409b84d9b 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -2,7 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -67,7 +67,7 @@ export const ClaudeExecutableFileCheck = Context.Reference( */ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")( function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; if (platform !== "win32") { return binaryPath; } diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 8104401868a3..78bb34c2fd9e 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,11 +1,10 @@ -import * as NodeOS from "node:os"; - import type { ClaudeSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); @@ -21,16 +20,17 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; const homePath = config.homePath.trim(); if (homePath.length > 0) { - return path.resolve(expandHomePath(homePath)); + return path.resolve(expandHomePath(homePath, home)); } // Inherited env vars are not shell-expanded, so a literal `~` stays literal. const inherited = environment?.CLAUDE_CONFIG_DIR?.trim() ?? ""; if (inherited.length > 0) { return path.resolve(inherited); } - return path.resolve(path.join(NodeOS.homedir(), ".claude")); + return path.resolve(path.join(home, ".claude")); }); export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 3ef7d925e031..40ab84d65ed6 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -13,14 +13,13 @@ * * @module provider/Drivers/ClaudeSkills */ -import * as NodeOS from "node:os"; import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { parse as parseYamlDocument } from "yaml"; @@ -243,7 +242,7 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const overridesByName = new Map(); const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); @@ -296,9 +295,10 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct cwd?: string, ): Effect.fn.Return { const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; const homePath = config.homePath.trim(); if (homePath.length > 0) { - return path.resolve(expandHomePath(homePath)); + return path.resolve(expandHomePath(homePath, home)); } // No tilde expansion here: the spawned CLI receives this env var verbatim // (env vars are never shell-expanded), so a literal `~` must stay literal @@ -309,7 +309,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct if (environmentConfigDir.length > 0) { return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); } - return path.join(NodeOS.homedir(), ".claude"); + return path.join(home, ".claude"); }); /** diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 54053e1dc9b8..7ac25a2e6bc1 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -7,7 +7,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -88,7 +88,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); // The `#!/bin/sh` stub below cannot be resolved as an executable on Windows. -const windowsHost = HostProcessPlatform.defaultValue() === "win32"; +const windowsHost = HostProcess.Platform.defaultValue() === "win32"; const noSpawn = ChildProcessSpawner.make(() => Effect.die("Disabled Codex must not spawn a process"), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 660c0dad965a..8aa546d103e1 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -73,9 +73,11 @@ import { resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; +import { codexUsageReader } from "./codexUsage.ts"; import * as CodexInstallation from "../CodexInstallation.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); @@ -125,7 +127,7 @@ export type CodexDriverEnv = | ServerEnvironment.ServerEnvironmentIdentity | CodexInstallation.CodexInstallation; -export const CodexDriver: ProviderDriver = { +export const CodexDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { displayName: "Codex", @@ -133,6 +135,7 @@ export const CodexDriver: ProviderDriver = { }, configSchema: CodexSettings, defaultConfig: (): CodexSettings => decodeCodexSettings({}), + usage: codexUsageReader, create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { if (config.setupMode === "managed") @@ -152,7 +155,7 @@ export const CodexDriver: ProviderDriver = { const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const modelCatalog = yield* ModelCatalog.ModelCatalog; const currentCatalog = modelCatalog.current(DRIVER_KIND); - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ @@ -176,7 +179,7 @@ export const CodexDriver: ProviderDriver = { const effectiveConfig = { ...config, enabled, - binaryPath: expandHomePath(config.binaryPath), + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2c78ffd18662..5323b6a26673 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -140,6 +140,34 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "replaces a Codex-created local sqlite maintenance lock with the shared lock", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const sharedLock = path.join(sharedHome, ".sqlite-maintenance.lock"); + const shadowLock = path.join(shadowHome, ".sqlite-maintenance.lock"); + + yield* writeTextFile(sharedLock, ""); + yield* writeTextFile(shadowLock, ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.readLink(shadowLock)).toBe(sharedLock); + }), + ); + it.effect.skipIf(!symlinksSupported)( "replaces Codex-created local MCP OAuth locks with the shared lock directory", () => diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 544e05744832..9d4410da9843 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -8,6 +6,7 @@ import * as Schema from "effect/Schema"; import * as PlatformError from "effect/PlatformError"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as HostProcess from "@t3tools/shared/HostProcess"; export interface CodexHomeLayout { readonly mode: "direct" | "authOverlay"; @@ -31,13 +30,13 @@ const KNOWN_SHARED_DIRECTORIES = [ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); -const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); +// Codex creates these runtime locks itself when it starts in a shadow home before the shared +// home has one. They guard shared state, so a local copy is replaced with the shared link. +const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set(["mcp-oauth-locks", ".sqlite-maintenance.lock"]); -function resolveHomePath(path: Path.Path, value: string | undefined): string { +function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { const expanded = - value && value.trim().length > 0 - ? expandHomePath(value) - : path.join(NodeOS.homedir(), ".codex"); + value && value.trim().length > 0 ? expandHomePath(value, home) : path.join(home, ".codex"); return path.resolve(expanded); } @@ -45,7 +44,8 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi config: CodexSettings, ): Effect.fn.Return { const path = yield* Path.Path; - const sharedHomePath = resolveHomePath(path, config.homePath); + const home = yield* HostProcess.HomeDirectory; + const sharedHomePath = resolveHomePath(path, home, config.homePath); const shadowHomePath = config.shadowHomePath.trim(); if (shadowHomePath.length === 0) { return { @@ -56,7 +56,7 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi }; } - const effectiveHomePath = path.resolve(expandHomePath(shadowHomePath)); + const effectiveHomePath = path.resolve(expandHomePath(shadowHomePath, home)); return { mode: "authOverlay", sharedHomePath, @@ -243,7 +243,7 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu ); if (state._tag === "NotSymlink") { - if (!REPLACEABLE_SHARED_RUNTIME_DIRECTORIES.has(input.entryName)) { + if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) { return yield* new CodexShadowHomeEntryConflictError({ sharedHomePath: input.sharedHomePath, effectiveHomePath: input.effectiveHomePath, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index a7181352b288..92c461390aea 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -36,7 +36,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu instanceId, enabled, config, - environment: mergeProviderInstanceEnvironment(input.environment), + environment: yield* mergeProviderInstanceEnvironment(input.environment), }); const continuationIdentity = codexContinuationIdentity(runtime.homeLayout); const stamp = withInstanceIdentity({ diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts new file mode 100644 index 000000000000..20c815fc9b32 --- /dev/null +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts @@ -0,0 +1,448 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime or sub-millisecond mtime, which the cache tests compare. +import * as NodeFSP from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; + +/** A writable connection that stays open until the enclosing scope closes. */ +const openDatabase = Effect.fn("openDatabase")(function* (filename: string) { + const context = yield* Layer.build(NodeSqliteClient.layer({ filename })); + const sql = Context.get(context, SqlClient.SqlClient); + return { + exec: (statement: string) => sql.unsafe(statement), + run: (statement: string, params: ReadonlyArray) => sql.unsafe(statement, params), + }; +}); + +/** Seeds `filename` and closes it, as a provider that has finished writing. */ +const seedDatabase = ( + filename: string, + seed: (db: Effect.Success>) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + yield* seed(yield* openDatabase(filename)); + }), + ); + +const tempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" }); +}); + +function protoNumber(field: number, value: number): number[] { + const varint = (number: number) => { + const bytes: number[] = []; + do { + const byte = number % 128; + number = Math.floor(number / 128); + bytes.push(byte + (number > 0 ? 128 : 0)); + } while (number > 0); + return bytes; + }; + return [...varint(field * 8), ...varint(value)]; +} + +function protoBytes(field: number, bytes: readonly number[]): number[] { + const encoded = protoNumber(field, bytes.length); + encoded[0] = encoded[0]! + 2; + return [...encoded, ...bytes]; +} + +function protoText(field: number, value: string): number[] { + return protoBytes(field, [...Buffer.from(value)]); +} + +const TABLES = + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)"; + +const createTables = (db: Effect.Success>) => + Effect.forEach(TABLES.split("; "), db.exec, { discard: true }); + +const records = (result: Effect.Success>) => + result.files.flatMap((file) => file.records); + +describe("readAntigravityUsage", () => { + it.effect( + "deduplicates Antigravity generation and step usage while preserving retry model and token buckets", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + const stamp = protoNumber(1, 1780000000); + const usage = [ + ...protoNumber(2, 100), + ...protoNumber(3, 40), + ...protoNumber(4, 5), + ...protoNumber(5, 20), + ...protoNumber(9, 10), + ...protoText(11, "response-1"), + ]; + const retry = [ + ...protoNumber(1, 1026), + ...protoNumber(2, 12), + ...protoNumber(3, 3), + ...protoText(11, "retry-1"), + ]; + const generation = protoBytes(1, [ + ...protoBytes(4, usage), + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(9, protoBytes(4, stamp)), + ]); + const step = [ + ...protoBytes(9, usage), + ...protoBytes(8, stamp), + ...protoBytes(28, protoBytes(2, retry)), + ]; + yield* seedDatabase(path.join(dir, "session-1.db"), (db) => + Effect.gen(function* () { + yield* createTables(db); + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + 0, + new Uint8Array(generation), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [0, new Uint8Array(step)]); + }), + ); + const result = yield* readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + const read = records(result); + assert.strictEqual(read.length, 2); + const main = read.find((record) => record.model === "gemini-3-pro"); + assert.isDefined(main); + assert.strictEqual(main?.timestampMs, 1780000000000); + assert.strictEqual(main?.sessionId, "session-1"); + assert.deepStrictEqual(main?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 40, + reasoningTokens: 10, + }); + assert.strictEqual( + read.find((record) => record.model === "claude-opus-4-6")?.totals.uncachedInputTokens, + 12, + ); + assert.deepStrictEqual(records(yield* readAntigravityUsage(dir, 1780000000001)), []); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("uses the matching Antigravity generation model for each model-less step", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + yield* seedDatabase(path.join(dir, "model-switch.db"), (db) => + Effect.gen(function* () { + yield* createTables(db); + for (const [idx, name] of ["Gemini 3 Pro", "Claude Opus 4.6"].entries()) { + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + idx, + new Uint8Array(protoBytes(1, protoText(19, name))), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + idx, + new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx))), + ]); + } + }), + ); + const result = yield* readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.deepStrictEqual( + records(result).map((record) => record.model), + ["gemini-3-pro", "claude-opus-4-6"], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("merges Antigravity aliases that bridge previously separate step records", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + yield* seedDatabase(path.join(dir, "bridge.db"), (db) => + Effect.gen(function* () { + yield* createTables(db); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 0, + new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 1, + new Uint8Array(protoBytes(9, [...protoNumber(3, 40), ...protoText(12, "provider")])), + ]); + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + 0, + new Uint8Array( + protoBytes(1, [ + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(4, [ + ...protoNumber(2, 50), + ...protoNumber(5, 20), + ...protoText(11, "response"), + ...protoText(12, "provider"), + ]), + ]), + ), + ]); + }), + ); + const result = yield* readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + const read = records(result); + assert.strictEqual(read.length, 1); + assert.deepStrictEqual(read[0]?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 20, + cacheCreationTokens: 0, + outputTokens: 40, + reasoningTokens: 0, + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "merges Antigravity provider and message aliases across configured roots while keeping original ownership", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* tempDir; + const roots = [path.join(dir, "first"), path.join(dir, "second")]; + for (const [index, root] of roots.entries()) { + yield* fileSystem.makeDirectory(root); + yield* seedDatabase(path.join(root, `session-${index}.db`), (db) => + Effect.gen(function* () { + yield* db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + for (const identity of [7, 12]) { + const usage = [ + ...protoNumber(1, 246), + ...protoNumber(2, index === 0 ? 100 : 150), + ...protoText(11, `response-${index}-${identity}`), + ...protoText(identity, `shared-${identity}`), + ]; + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + identity, + new Uint8Array(protoBytes(9, usage)), + ]); + } + }), + ); + } + const result = yield* readAntigravityUsage(roots, 0); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0]?.root, roots[0]); + assert.strictEqual(result.files[0]?.records.length, 2); + assert.strictEqual(result.files[1]?.records.length, 0); + assert.deepStrictEqual( + result.files[0]?.records.map((record) => record.totals.uncachedInputTokens), + [150, 150], + ); + assert.isTrue(result.files[0]?.records.every((record) => record.sessionId === "session-0")); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("upgrades Antigravity fallback timestamps before applying the date window", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* tempDir; + for (const fallback of ["mtime", "trajectory"]) { + const file = path.join(dir, `${fallback}.db`); + yield* seedDatabase(file, (db) => + Effect.gen(function* () { + yield* createTables(db); + if (fallback === "trajectory") { + yield* db.exec("CREATE TABLE trajectory_metadata_blob (data BLOB)"); + yield* db.run("INSERT INTO trajectory_metadata_blob VALUES (?)", [ + new Uint8Array(protoBytes(2, protoNumber(1, 1780000200))), + ]); + } + for (const [index, seconds] of [1780000000, 1780000200].entries()) { + const usage = [...protoNumber(2, 10), ...protoText(11, `${fallback}-${index}`)]; + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + index, + new Uint8Array(protoBytes(9, usage)), + ]); + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + index, + new Uint8Array( + protoBytes(1, [ + ...protoBytes(4, usage), + ...protoBytes(9, protoBytes(4, protoNumber(1, seconds))), + ]), + ), + ]); + } + }), + ); + yield* fileSystem.utimes(file, 1780000000, 1780000000); + } + const result = yield* readAntigravityUsage(dir, 1780000100000); + assert.deepStrictEqual(result.errors, []); + const read = records(result); + assert.strictEqual(read.length, 2); + assert.deepStrictEqual( + read.map((record) => record.timestampMs), + [1780000200000, 1780000200000], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + const antigravityGeneration = (responseId: string) => + new Uint8Array( + protoBytes(1, [ + ...protoBytes(4, [ + ...protoNumber(2, 100), + ...protoNumber(3, 40), + ...protoText(11, responseId), + ]), + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(9, protoBytes(4, protoNumber(1, 1780000000))), + ]), + ); + + it.effect("reuses an unchanged Antigravity database instead of decoding it again", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + const db = yield* openDatabase(path.join(dir, "session-1.db")); + yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [0, antigravityGeneration("r-1")]); + const cache = makeAntigravityUsageCache(); + assert.deepStrictEqual((yield* readAntigravityUsage(dir, 0, cache)).errors, []); + + // An exclusive lock makes a fresh read fail without touching the file, so + // only a cache hit can still return the earlier records. + yield* db.exec("BEGIN EXCLUSIVE"); + assert.strictEqual((yield* readAntigravityUsage(dir, 0)).errors.length, 1); + const cached = yield* readAntigravityUsage(dir, 0, cache); + assert.deepStrictEqual(cached.errors, []); + assert.deepStrictEqual( + records(cached).map((record) => record.dedupeKey), + ["antigravity:11:r-1"], + ); + yield* db.exec("ROLLBACK"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rereads an Antigravity database rewritten with its size and mtime restored", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const dir = yield* tempDir; + const path = pathService.join(dir, "session-1.db"); + yield* seedDatabase(path, (db) => + Effect.gen(function* () { + yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + 0, + antigravityGeneration("r-1"), + ]); + }), + ); + yield* fileSystem.utimes(path, 1780000000, 1780000000); + const cache = makeAntigravityUsageCache(); + assert.deepStrictEqual((yield* readAntigravityUsage(dir, 0, cache)).errors, []); + + // ctime has the kernel's timestamp granularity, which can be a few + // milliseconds, so repeat the forged rewrite until it lands on a later tick + // than the cached read, as any real rewrite does. + const stat = () => Effect.promise(() => NodeFSP.stat(path)); + const cached = yield* stat(); + do { + yield* fileSystem.writeFile(path, new Uint8Array(cached.size)); + yield* fileSystem.utimes(path, 1780000000, 1780000000); + } while ((yield* stat()).ctimeMs === cached.ctimeMs); + const restored = yield* stat(); + assert.strictEqual(restored.size, cached.size); + assert.strictEqual(restored.mtimeMs, cached.mtimeMs); + const next = yield* readAntigravityUsage(dir, 0, cache); + assert.deepStrictEqual(next.errors, [path]); + assert.deepStrictEqual(records(next), []); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rereads an Antigravity database when only its WAL changed", () => + Effect.gen(function* () { + const pathService = yield* Path.Path; + const dir = yield* tempDir; + const path = pathService.join(dir, "session-1.db"); + const db = yield* openDatabase(path); + yield* db.exec("PRAGMA journal_mode = WAL"); + yield* db.exec("PRAGMA wal_autocheckpoint = 0"); + yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); + const insert = (idx: number, responseId: string) => + db.run("INSERT INTO gen_metadata VALUES (?, ?)", [idx, antigravityGeneration(responseId)]); + yield* insert(0, "r-1"); + const cache = makeAntigravityUsageCache(); + const first = yield* readAntigravityUsage(dir, 0, cache); + assert.strictEqual(records(first).length, 1); + + const before = yield* Effect.promise(() => NodeFSP.stat(path)); + yield* insert(1, "r-2"); + const after = yield* Effect.promise(() => NodeFSP.stat(path)); + assert.strictEqual(after.size, before.size); + assert.strictEqual(after.mtimeMs, before.mtimeMs); + + const next = yield* readAntigravityUsage(dir, 0, cache); + assert.deepStrictEqual( + records(next).map((record) => record.dedupeKey), + ["antigravity:11:r-1", "antigravity:11:r-2"], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reads Antigravity step-only stores and reports malformed databases", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* tempDir; + yield* seedDatabase(path.join(dir, "steps.db"), (db) => + Effect.gen(function* () { + yield* db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...protoNumber(3, 5)]; + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 0, + new Uint8Array([...protoBytes(9, usage), ...protoBytes(8, protoNumber(1, 1780000000))]), + ]); + }), + ); + yield* fileSystem.writeFileString(path.join(dir, "broken.db"), "not a sqlite database"); + const result = yield* readAntigravityUsage(dir, 0); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(records(result)[0]?.model, "gemini-2.5-pro"); + assert.strictEqual(records(result)[0]?.totals.outputTokens, 5); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("ignores large values in unused Antigravity protobuf fields", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + yield* seedDatabase(path.join(dir, "large-varint.db"), (db) => + Effect.gen(function* () { + yield* db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + const unusedField = [...protoNumber(99, 0).slice(0, -1), ...Array(9).fill(0xff), 0x01]; + const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...unusedField]; + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 0, + new Uint8Array(protoBytes(9, usage)), + ]); + }), + ); + const result = yield* readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(records(result)[0]?.totals.uncachedInputTokens, 10); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts similarity index 56% rename from apps/server/src/usage/antigravityUsageReader.ts rename to apps/server/src/provider/Drivers/antigravityUsageReader.ts index 8b1559cfedd7..e48609cf874d 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -1,11 +1,14 @@ -// node:sqlite reads live conversation databases while Node fs discovers them. -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime, which the cache fingerprint needs. import * as NodeFSP from "node:fs/promises"; -import * as NodePath from "node:path"; -import * as NodeSqlite from "node:sqlite"; -import * as NodeTimersPromises from "node:timers/promises"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import type { UsageRecord } from "@t3tools/provider-core/server/usage"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; type FieldValue = number | bigint | Uint8Array; type Fields = Map; @@ -158,103 +161,136 @@ interface UsageCandidate { timestampQuality: number; } -async function readDatabase(path: string, fallbackTimestamp: number): Promise { - const db = new NodeSqlite.DatabaseSync(path, { readOnly: true }); - try { - db.exec("PRAGMA busy_timeout = 100; BEGIN"); +/** A conversation database whose usage tables are missing or undecodable. */ +class AntigravityDatabaseDecodeError extends Schema.TaggedError()( + "AntigravityDatabaseDecodeError", + { path: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `Antigravity usage could not be decoded from ${this.path}.`; + } +} + +const readDatabase = Effect.fn("readDatabase")(function* ( + path: string, + sessionId: string, + fallbackTimestamp: number, +) { + const sql = yield* SqlClient.SqlClient; + const decode =
(evaluate: () => A) => + Effect.try({ + try: evaluate, + catch: (cause) => new AntigravityDatabaseDecodeError({ path, cause }), + }); + yield* sql.unsafe("PRAGMA busy_timeout = 100"); + return yield* Effect.gen(function* () { const tables = new Set( - db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") - .all() - .map((row) => row.name), + (yield* sql.unsafe<{ readonly name: unknown }>( + "SELECT name FROM sqlite_master WHERE type = 'table'", + )).map((row) => row.name), ); if (!tables.has("gen_metadata") && !tables.has("steps")) { - throw new Error("Missing Antigravity usage tables"); + return yield* new AntigravityDatabaseDecodeError({ path }); } - const readMetadata = async (query: string, column: string, step: boolean) => { + const readMetadata = Effect.fnUntraced(function* ( + query: string, + column: string, + step: boolean, + ) { const entries: Array<{ idx: number; entry: Metadata }> = []; - for (const row of db.prepare(query).iterate()) { - if (typeof row.idx !== "number") throw new Error("Invalid Antigravity metadata index"); - entries.push({ idx: row.idx, entry: metadata(blob(row[column]), step) }); - if (entries.length % 256 === 0) await NodeTimersPromises.setImmediate(); + for (const row of yield* sql.unsafe>(query)) { + const idx = row.idx; + if (typeof idx !== "number") { + return yield* new AntigravityDatabaseDecodeError({ path }); + } + entries.push({ idx, entry: yield* decode(() => metadata(blob(row[column]), step)) }); + if (entries.length % 256 === 0) yield* Effect.yieldNow; } return entries; - }; + }); const generations = tables.has("gen_metadata") - ? await readMetadata("SELECT idx, data FROM gen_metadata ORDER BY idx", "data", false) + ? yield* readMetadata("SELECT idx, data FROM gen_metadata ORDER BY idx", "data", false) : []; let trajectoryTimestamp: number | null = null; if (tables.has("trajectory_metadata_blob")) { - for (const row of db.prepare("SELECT data FROM trajectory_metadata_blob").iterate()) { - trajectoryTimestamp ??= timestamp(nested(fields(blob(row.data)), 2)); + for (const row of yield* sql.unsafe<{ readonly data: unknown }>( + "SELECT data FROM trajectory_metadata_blob", + )) { + trajectoryTimestamp ??= yield* decode(() => timestamp(nested(fields(blob(row.data)), 2))); } } const steps = tables.has("steps") - ? await readMetadata( + ? yield* readMetadata( "SELECT idx, metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx", "metadata", true, ) : []; - const sessionId = NodePath.basename(path, ".db"); - const records: UsageCandidate[] = []; - const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model])); - for (const [source, entries] of [ - ["step", steps], - ["generation", generations], - ] as const) { - for (const [index, { idx, entry }] of entries.entries()) { - for (const [usageIndex, usage] of entry.usages.entries()) { - const outputTokens = Math.max( - numberAt(usage, 3), - numberAt(usage, 9) + numberAt(usage, 10), - ); - const totals = { - uncachedInputTokens: numberAt(usage, 2), - cachedInputTokens: numberAt(usage, 5), - cacheCreationTokens: numberAt(usage, 4), - outputTokens, - reasoningTokens: Math.min(outputTokens, numberAt(usage, 9)), - }; - if ( - totals.uncachedInputTokens + - totals.cachedInputTokens + - totals.cacheCreationTokens + - outputTokens === - 0 - ) - continue; - const keys = ([11, 12, 7] as const).flatMap((key) => { - const id = textAt(usage, key); - return id ? [`antigravity:${key}:${id}`] : []; - }); - const record: UsageRecord = { - provider: "antigravity", - sessionId, - timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp, - model: - MODEL_IDS[numberAt(usage, 1)] || - entry.model || - (source === "step" ? generationModels.get(idx) : "") || - modelName("", numberAt(usage, 1)) || - "antigravity-unknown", - totals, - reportedCostUsd: null, - speed: "standard", - dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`, - }; - records.push({ - record, - keys, - timestampQuality: entry.timestampMs !== null ? 2 : trajectoryTimestamp !== null ? 1 : 0, - }); - } + return yield* decode(() => + usageCandidates(sessionId, generations, steps, trajectoryTimestamp, fallbackTimestamp), + ); + }).pipe(sql.withTransaction); +}); + +function usageCandidates( + sessionId: string, + generations: ReadonlyArray<{ idx: number; entry: Metadata }>, + steps: ReadonlyArray<{ idx: number; entry: Metadata }>, + trajectoryTimestamp: number | null, + fallbackTimestamp: number, +): UsageCandidate[] { + const records: UsageCandidate[] = []; + const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model])); + for (const [source, entries] of [ + ["step", steps], + ["generation", generations], + ] as const) { + for (const [index, { idx, entry }] of entries.entries()) { + for (const [usageIndex, usage] of entry.usages.entries()) { + const outputTokens = Math.max(numberAt(usage, 3), numberAt(usage, 9) + numberAt(usage, 10)); + const totals = { + uncachedInputTokens: numberAt(usage, 2), + cachedInputTokens: numberAt(usage, 5), + cacheCreationTokens: numberAt(usage, 4), + outputTokens, + reasoningTokens: Math.min(outputTokens, numberAt(usage, 9)), + }; + if ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + outputTokens === + 0 + ) + continue; + const keys = ([11, 12, 7] as const).flatMap((key) => { + const id = textAt(usage, key); + return id ? [`antigravity:${key}:${id}`] : []; + }); + const record: UsageRecord = { + provider: "antigravity", + sessionId, + timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp, + model: + MODEL_IDS[numberAt(usage, 1)] || + entry.model || + (source === "step" ? generationModels.get(idx) : "") || + modelName("", numberAt(usage, 1)) || + "antigravity-unknown", + totals, + reportedCostUsd: null, + speed: "standard", + dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`, + }; + records.push({ + record, + keys, + timestampQuality: entry.timestampMs !== null ? 2 : trajectoryTimestamp !== null ? 1 : 0, + }); } } - return records; - } finally { - db.close(); } + return records; } interface CachedDatabase { @@ -275,11 +311,13 @@ export const makeAntigravityUsageCache = () => new Map() * filtering. With a cache, databases unchanged since the previous read are not * decoded again. */ -export async function readAntigravityUsage( +export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* ( conversationsDirectories: string | readonly string[], sinceMs: number, cache?: Map, ) { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; const roots = typeof conversationsDirectories === "string" ? [conversationsDirectories] @@ -350,50 +388,69 @@ export async function readAntigravityUsage( } }; const visited = new Set(); - const walk = async (directory: string, root: string): Promise => { - let entries; - try { - entries = await NodeFSP.readdir(directory, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(directory); - return; - } - entries.sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - const path = NodePath.join(directory, entry.name); - if (entry.isDirectory()) { - await walk(path, root); - } else if (entry.isFile() && entry.name.endsWith(".db")) { - try { - const canonical = await NodeFSP.realpath(path); - if (visited.has(canonical)) continue; - visited.add(canonical); - const stat = await NodeFSP.stat(path); - const wal = await NodeFSP.stat(`${path}-wal`).catch(() => null); - const fingerprint = [stat, wal] - .map((file) => (file ? `${file.size}:${file.mtimeMs}:${file.ctimeMs}` : "-")) - .join("/"); - const cached = cache?.get(canonical); - let candidates: readonly UsageCandidate[]; - if (cached?.fingerprint === fingerprint) { - candidates = cached.candidates; - } else { - candidates = await readDatabase(path, stat.mtimeMs); - cache?.set(canonical, { fingerprint, candidates }); - } + const readFile = Effect.fn("readAntigravityUsage.readFile")(function* (path: string) { + const canonical = yield* fileSystem.realPath(path); + if (visited.has(canonical)) return null; + visited.add(canonical); + const fingerprintOf = (file: string) => + Effect.tryPromise(() => NodeFSP.stat(file)).pipe( + Effect.map((stat) => ({ stat, part: `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` })), + ); + const { stat, part } = yield* fingerprintOf(path); + const wal = yield* fingerprintOf(`${path}-wal`).pipe(Effect.option); + const fingerprint = `${part}/${wal._tag === "Some" ? wal.value.part : "-"}`; + const cached = cache?.get(canonical); + if (cached?.fingerprint === fingerprint) return cached.candidates; + const candidates = yield* readDatabase( + path, + pathService.basename(path, ".db"), + stat.mtimeMs, + ).pipe(Effect.provide(NodeSqliteClient.layer({ filename: path, readonly: true }))); + cache?.set(canonical, { fingerprint, candidates }); + return candidates; + }); + const walk = (directory: string, root: string): Effect.Effect => + Effect.gen(function* () { + const names = yield* fileSystem.readDirectory(directory).pipe( + Effect.catchTags({ + PlatformError: (cause) => { + if (cause.reason._tag !== "NotFound") errors.push(directory); + return Effect.succeed(null); + }, + }), + ); + if (names === null) return; + names.sort((a, b) => a.localeCompare(b)); + for (const name of names) { + const path = pathService.join(directory, name); + // Like a directory listing's entry types: symlinks are neither files nor directories. + const isLink = Exit.isSuccess(yield* Effect.exit(fileSystem.readLink(path))); + const type = isLink + ? "SymbolicLink" + : yield* fileSystem.stat(path).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => null), + ); + if (type === "Directory") { + yield* walk(path, root); + } else if ((type === "File" || type === null) && name.endsWith(".db")) { + const candidates = yield* readFile(path).pipe( + Effect.catch(() => { + errors.push(path); + return Effect.succeed(null); + }), + ); + if (candidates === null) continue; const fileIndex = files.length; files.push({ root, path, records: [] }); for (const [index, candidate] of candidates.entries()) { append(candidate, fileIndex); - if (index % 256 === 255) await NodeTimersPromises.setImmediate(); + if (index % 256 === 255) yield* Effect.yieldNow; } - } catch { - errors.push(path); } } - } - }; - for (const root of roots) await walk(root, root); + }); + for (const root of roots) yield* walk(root, root); if (cache !== undefined) { for (const key of cache.keys()) if (!visited.has(key)) cache.delete(key); } @@ -403,4 +460,4 @@ export async function readAntigravityUsage( } } return { files, errors }; -} +}); diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts new file mode 100644 index 000000000000..8c2d08c36e2a --- /dev/null +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { parseClaudeLine } from "./claudeUsage.ts"; + +/** Shaped after a real Claude Code assistant record. */ +function claudeLine(overrides: { + messageId: string; + contentType: string; + model?: string; + outputTokens?: number; + speed?: string; +}): string { + return JSON.stringify({ + type: "assistant", + timestamp: "2026-08-07T04:05:13.944Z", + sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", + cwd: "/home/theo/project", + message: { + id: overrides.messageId, + role: "assistant", + model: overrides.model ?? "claude-fable-5", + content: [{ type: overrides.contentType }], + usage: { + input_tokens: 2, + cache_creation_input_tokens: 66818, + cache_read_input_tokens: 1000, + output_tokens: overrides.outputTokens ?? 286, + ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), + }, + }, + }); +} + +describe("parseClaudeLine", () => { + it("extracts token totals and a dedupe key", () => { + const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); + + expect(record).not.toBeNull(); + expect(record?.provider).toBe("claude"); + expect(record?.model).toBe("claude-fable-5"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 66818, + outputTokens: 286, + reasoningTokens: 0, + }); + expect(record?.dedupeKey).toBe("msg_1:"); + expect(record?.speed).toBe("standard"); + }); + + it("marks fast-mode requests", () => { + const line = (speed: string) => + parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); + + expect(line("fast")?.speed).toBe("fast"); + expect(line("standard")?.speed).toBe("standard"); + }); + + it("gives every content block of one message the same dedupe key", () => { + // T3 Code writes one record per content block, each repeating the parent + // message's full usage. Summing them would overcount ~2.4x on real data. + const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); + const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); + + expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); + expect(text?.totals).toEqual(toolUse?.totals); + }); + + it("ignores records that are not assistant messages", () => { + expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); + expect(parseClaudeLine("not json")).toBeNull(); + }); +}); diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts new file mode 100644 index 000000000000..2bab4f5e92b6 --- /dev/null +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -0,0 +1,121 @@ +/** + * Usage history for Claude Code: the transcript format of its + * `/projects/**.jsonl` session files and where each instance keeps them. + * + * @module provider/Drivers/claudeUsage + */ + +import type { ClaudeSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { + parseTimestampMs, + tokenCount, + type ProviderUsageReader, + type TranscriptUsageFormat, + type UsageRecord, +} from "@t3tools/provider-core/server/usage"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +/** + * Parses one line of a Claude Code transcript. + * + * T3 Code writes one record per assistant *content block*, and every one of + * those records repeats the same complete `usage` object for the parent + * message. Summing them overcounts by roughly 2.4x on a real workload, so the + * caller must drop repeats by `dedupeKey` and keep the first. + */ +export function parseClaudeLine(line: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + return parseClaudeRecord(parsed); +} + +function parseClaudeRecord(parsed: unknown): UsageRecord | null { + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (record["type"] !== "assistant") return null; + + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const messageRecord = message as Record; + + const usage = messageRecord["usage"]; + if (typeof usage !== "object" || usage === null) return null; + const usageRecord = usage as Record; + + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + + const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; + if (model.length === 0) return null; + + const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; + const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; + // Matches ccusage: prefer the message/request pair, fall back to whichever + // half exists. Records with neither cannot be de-duplicated. + const dedupeKey = + messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; + + const cost = record["costUSD"]; + + return { + provider: "claude", + timestampMs, + model, + sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + totals: { + uncachedInputTokens: tokenCount(usageRecord["input_tokens"]), + cachedInputTokens: tokenCount(usageRecord["cache_read_input_tokens"]), + cacheCreationTokens: tokenCount(usageRecord["cache_creation_input_tokens"]), + outputTokens: tokenCount(usageRecord["output_tokens"]), + // Anthropic folds thinking tokens into output and does not break them out. + reasoningTokens: 0, + }, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, + speed: usageRecord["speed"] === "fast" ? "fast" : "standard", + dedupeKey, + }; +} + +const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] => + record === null ? [] : [record]; + +export const claudeUsageFormat: TranscriptUsageFormat = { + // Keeps the dedupe and cost metadata. The `usage` subtree keeps future token fields. + selectFields: { + type: true, + timestamp: true, + requestId: true, + sessionId: true, + costUSD: true, + message: { id: true, model: true, usage: true }, + }, + mightCarryUsage: (line) => line.includes('"usage"'), + parseLine: (line) => orEmpty(parseClaudeLine(line)), + parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)), +}; + +export const claudeUsageReader: ProviderUsageReader = { + kind: "transcripts", + provider: "claude", + format: claudeUsageFormat, + directories: Effect.fn("claudeUsageReader.directories")(function* ({ config, environment }) { + // An undecodable config has no trustworthy home to read. + if (config === undefined) return []; + const path = yield* Path.Path; + const homeDirectory = yield* HostProcess.HomeDirectory; + const configured = config.homePath.trim(); + const home = configured + ? expandHomePath(configured, homeDirectory) + : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDirectory, ".claude"); + return [{ dir: path.resolve(home, "projects") }]; + }), +}; diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts new file mode 100644 index 000000000000..36f15dbdf468 --- /dev/null +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { initialCodexScanState, parseCodexLine } from "./codexUsage.ts"; + +describe("parseCodexLine", () => { + const sessionMeta = JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T05:17:41.289Z", + payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + }); + const turnContext = JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T05:17:42.694Z", + payload: { type: "turn_context", model: "gpt-5.6-sol" }, + }); + const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:49.919Z", + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: cached, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: reasoning, + }, + }, + }, + }); + + it("attributes usage to the model from the preceding turn context", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state); + + expect(record?.provider).toBe("codex"); + expect(record?.model).toBe("gpt-5.6-sol"); + expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); + // Codex reports input_tokens inclusive of the cached portion. + expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); + expect(record?.totals.cachedInputTokens).toBe(11008); + expect(record?.totals.reasoningTokens).toBe(116); + }); + + it("skips a repeated token_count so deltas are not double counted", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); + const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(first).not.toBeNull(); + expect(repeat).toBeNull(); + }); + + it("drops usage that arrives before any model is known", () => { + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + }); + + it("does not let a pre-model event poison the duplicate signature", () => { + // A token_count before its turn_context is dropped; the identical event + // re-emitted once the model is known must still be counted. + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + parseCodexLine(turnContext, state); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); + }); + + it("carries the service tier from the latest thread settings", () => { + const settings = (thread_settings: Record) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:42.000Z", + payload: { type: "thread_settings_applied", thread_settings }, + }); + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const speedAfter = (line: string, output: number) => { + parseCodexLine(line, state); + return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed; + }; + + expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard"); + expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast"); + expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast"); + // Codex omits the field when no tier was requested. + expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard"); + }); + + // A forked/subagent rollout opens with the parent's history copied in and + // every line re-stamped to the fork instant, then the ancestors' session + // metas. Counting those again multiplied usage ~1.85x on real data (#5758). + describe("forked rollouts", () => { + const meta = (overrides: { + id: string; + timestamp: string; + forkedFromId?: string; + spawnParentId?: string; + }) => + JSON.stringify({ + type: "session_meta", + timestamp: overrides.timestamp, + payload: { + type: "session_meta", + id: overrides.id, + ...(overrides.forkedFromId === undefined + ? {} + : { forked_from_id: overrides.forkedFromId }), + ...(overrides.spawnParentId === undefined + ? {} + : { + source: { + subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } }, + }, + }), + }, + }); + const stamped = (timestamp: string, line: string) => { + const parsed = JSON.parse(line) as { timestamp: string }; + parsed.timestamp = timestamp; + return JSON.stringify(parsed); + }; + + it("keeps the child session id over copied ancestor metas", () => { + const state = initialCodexScanState(); + parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(record?.sessionId).toBe("child"); + }); + + it("drops the re-stamped copied burst and keeps the first real event", () => { + const state = initialCodexScanState(); + const forkInstant = "2026-08-01T05:00:00.000Z"; + parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); + parseCodexLine(meta({ id: "parent", timestamp: forkInstant }), state); + parseCodexLine(stamped(forkInstant, turnContext), state); + + // Copied history: written in one burst at the fork instant. + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), + ).toBeNull(); + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state), + ).toBeNull(); + + // The child's first genuine turn lands seconds later and must count. + const real = parseCodexLine( + stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)), + state, + ); + expect(real).not.toBeNull(); + expect(real?.totals.outputTokens).toBe(30); + + // Suppression never restarts, even for closely spaced later events. + const next = parseCodexLine( + stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)), + state, + ); + expect(next).not.toBeNull(); + }); + + it("recognizes subagent spawns without forked_from_id", () => { + const state = initialCodexScanState(); + const spawnInstant = "2026-08-01T05:00:00.000Z"; + parseCodexLine( + meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }), + state, + ); + parseCodexLine(stamped(spawnInstant, turnContext), state); + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), + ).toBeNull(); + }); + + it("does not suppress anything in a rollout that is not a fork", () => { + const state = initialCodexScanState(); + parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(stamped("2026-08-01T05:00:00.100Z", turnContext), state); + const record = parseCodexLine( + stamped("2026-08-01T05:00:00.200Z", tokenCount(100, 0, 10, 0)), + state, + ); + expect(record).not.toBeNull(); + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts new file mode 100644 index 000000000000..fc5294e20597 --- /dev/null +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -0,0 +1,265 @@ +/** + * Usage history for Codex: the rollout format of `/sessions/**.jsonl` + * and where each instance's shared home keeps them. + * + * @module provider/Drivers/codexUsage + */ +import type { CodexSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + parseTimestampMs, + tokenCount, + totalTokens, + type ProviderUsageReader, + type TranscriptUsageFormat, + type UsageRecord, + type UsageSpeed, +} from "@t3tools/provider-core/server/usage"; +import type { UsageTokenTotals } from "@t3tools/contracts"; + +import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts"; + +/** + * Rolling state for a single Codex rollout file. + * + * 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`. Sessions that switch either + * mid-run attribute correctly from the switch onward. + * + * Persisted in the scan cache at each resume point. The field order is the + * stored JSON's, so keep it stable. + */ +export const CodexScanState = Schema.Struct({ + model: Schema.mutableKey(Schema.String), + speed: Schema.mutableKey(Schema.Literals(["standard", "fast", "ultrafast"])), + sessionId: Schema.mutableKey(Schema.String), + lastUsageSignature: Schema.mutableKey(Schema.NullOr(Schema.String)), + sawSessionMeta: Schema.mutableKey(Schema.Boolean), + /** While true, leading usage events are re-stamped copies of parent history. */ + suppressingForkCopies: Schema.mutableKey(Schema.Boolean), + forkCopyAnchorMs: Schema.mutableKey(Schema.Finite), +}); +export type CodexScanState = typeof CodexScanState.Type; + +export function initialCodexScanState(): CodexScanState { + return { + model: "", + speed: "standard", + sessionId: "", + lastUsageSignature: null, + sawSessionMeta: false, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }; +} + +/** + * A forked or subagent rollout opens with the parent's full history copied in, + * every line re-stamped to the fork instant. Those copies are written in one + * synchronous burst (observed gaps 0-40ms), while the child's first genuine + * usage event only lands after a real model turn (observed 5s+). One second of + * separation splits the two cleanly; `ccusage` uses the same threshold. + */ +const FORK_COPY_MAX_GAP_MS = 1000; + +/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */ +function isForkedSessionMeta(payload: Record): boolean { + if (typeof payload["forked_from_id"] === "string") return true; + const source = payload["source"]; + if (typeof source !== "object" || source === null) return false; + const subagent = (source as Record)["subagent"]; + if (typeof subagent !== "object" || subagent === null) return false; + const spawn = (subagent as Record)["thread_spawn"]; + if (typeof spawn !== "object" || spawn === null) return false; + return typeof (spawn as Record)["parent_thread_id"] === "string"; +} + +/** + * Feeds one line of a Codex rollout into `state`, returning a record when the + * line was a usage event. + * + * Deltas come from `last_token_usage`. Summing those across a session + * reconciles with the session's final `total_token_usage`, provided + * consecutive duplicate events are dropped, which this does. + */ +export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + return parseCodexRecord(parsed, state); +} + +function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + const payload = record["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const payloadRecord = payload as Record; + const payloadType = payloadRecord["type"]; + + if (record["type"] === "session_meta") { + // Only the first meta describes this file's own session. A forked rollout + // repeats the ancestors' metas right after it; letting those through would + // reassign every subsequent record to an ancestor session. + if (state.sawSessionMeta) return null; + state.sawSessionMeta = true; + const id = payloadRecord["id"] ?? payloadRecord["session_id"]; + if (typeof id === "string") state.sessionId = id; + const metaTimestampMs = parseTimestampMs(record["timestamp"]); + if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { + state.suppressingForkCopies = true; + state.forkCopyAnchorMs = metaTimestampMs; + } + return null; + } + + if (record["type"] === "turn_context") { + if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + return null; + } + + if (payloadType === "thread_settings_applied") { + const settings = payloadRecord["thread_settings"]; + if (typeof settings === "object" && settings !== null) { + state.speed = codexSpeed((settings as Record)["service_tier"]); + } + return null; + } + + if (payloadType !== "token_count") return null; + + const info = payloadRecord["info"]; + if (typeof info !== "object" || info === null) return null; + const last = (info as Record)["last_token_usage"]; + if (typeof last !== "object" || last === null) return null; + const lastRecord = last as Record; + + // Only an event that is otherwise eligible may consume the duplicate + // signature. A token_count arriving before its turn_context (no model yet) + // must not poison it, or the re-emitted copy after the model is known would + // be skipped as a duplicate and those tokens never counted. + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + if (state.model.length === 0) return null; + + // Codex re-emits an unchanged token_count on some stream boundaries. Summing + // those would double count, so identical consecutive payloads are skipped. + const signature = JSON.stringify(lastRecord); + if (signature === state.lastUsageSignature) return null; + state.lastUsageSignature = signature; + + // In a forked rollout the copied parent history was already counted from the + // parent's own file. Drop the leading burst; the first usage event separated + // from its predecessor by a real turn's worth of time ends it for good. + if (state.suppressingForkCopies) { + if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { + state.forkCopyAnchorMs = timestampMs; + return null; + } + state.suppressingForkCopies = false; + } + + const inputTokens = tokenCount(lastRecord["input_tokens"]); + const cachedInputTokens = tokenCount(lastRecord["cached_input_tokens"]); + const cacheCreationTokens = tokenCount(lastRecord["cache_write_input_tokens"]); + const outputTokens = tokenCount(lastRecord["output_tokens"]); + + const totals: UsageTokenTotals = { + // Codex reports `input_tokens` inclusive of the cached portion. + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + // Reported inside output_tokens, surfaced separately for the token mix. + reasoningTokens: Math.min(outputTokens, tokenCount(lastRecord["reasoning_output_tokens"])), + }; + + if (totalTokens(totals) === 0) return null; + + return { + provider: "codex", + timestampMs, + model: state.model, + sessionId: state.sessionId, + totals, + // Codex does not report cost in the rollout. + reportedCostUsd: null, + speed: state.speed, + // Events surviving the fork-copy suppression above are unique to this + // rollout, so they need no global dedup. + dedupeKey: null, + }; +} + +/** + * Maps a Codex `service_tier` to its billing speed. Codex omits the field when + * no tier was requested, which bills as standard, as do `default` and + * `standard`. `fast` is accepted as an alias of `priority`. + */ +function codexSpeed(serviceTier: unknown): UsageSpeed { + if (serviceTier === "priority" || serviceTier === "fast") return "fast"; + if (serviceTier === "ultrafast") return "ultrafast"; + return "standard"; +} + +const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] => + record === null ? [] : [record]; + +export const codexUsageFormat: TranscriptUsageFormat = { + // Keeps the fields the reducer reads as well as the usage itself. + selectFields: { + type: true, + timestamp: true, + payload: { + type: true, + id: true, + session_id: true, + model: true, + thread_settings: { service_tier: true }, + forked_from_id: true, + source: { subagent: { thread_spawn: { parent_thread_id: true } } }, + info: { last_token_usage: true }, + }, + }, + // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no + // usage, but they carry the model, tier and session the reducer needs. + mightCarryUsage: (line) => + line.includes('"token_count"') || + line.includes('"turn_context"') || + line.includes('"thread_settings_applied"') || + line.includes('"session_meta"'), + parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), + parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), + state: { initial: initialCodexScanState, schema: CodexScanState }, + // A rollout that moved after it was read leaves a copy in both places. + sharedSessionsAcrossFiles: true, +}; + +export const codexUsageReader: ProviderUsageReader = { + kind: "transcripts", + provider: "codex", + format: codexUsageFormat, + directories: Effect.fn("codexUsageReader.directories")(function* ({ config, environment }) { + // An undecodable config has no trustworthy home to read. + if (config === undefined) return []; + const path = yield* Path.Path; + const environmentHome = environment.CODEX_HOME?.trim(); + const layout = yield* resolveCodexHomeLayout( + config.setupMode !== "managed" && + !config.homePath.trim() && + !config.shadowHomePath.trim() && + environmentHome + ? { ...config, homePath: environmentHome } + : config, + ); + return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }]; + }), +}; diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 36db18edb3b9..49d575f7eb66 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -39,7 +39,7 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import type { GrokSettings } from "@t3tools/provider-grok/settings"; import type { CursorSettings } from "@t3tools/provider-cursor/settings"; -import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -51,7 +51,6 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ServerConfig from "../config.ts"; -import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; @@ -153,7 +152,7 @@ const makeTildeProviderFixtures = Effect.fn( )(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const homePath = expandHomePath("~"); + const homePath = yield* HostProcess.HomeDirectory; const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ directory: homePath, prefix: ".t3-provider-path-test-", @@ -346,7 +345,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { it.live("reports Codex's answer when a redemption changed nothing", () => Effect.gen(function* () { - if (yield* isHostWindows) return; + if (yield* HostProcess.isWindows) return; const fileSystem = yield* FileSystem.FileSystem; const fixtures = yield* makeTildeProviderFixtures(); yield* fileSystem.writeFileString( @@ -383,7 +382,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { it.live("runs Codex and Claude readiness probes from configured tilde paths", () => Effect.gen(function* () { - if (yield* isHostWindows) return; + if (yield* HostProcess.isWindows) return; const fixtures = yield* makeTildeProviderFixtures(); @@ -500,7 +499,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { return { outcome, after: yield* instance!.snapshot.getSnapshot }; }).pipe( // macOS logins live in the Keychain, where resets are never read. - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), Effect.provide(layerTest), ); diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 65711612b18c..f312b3d58320 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -4,6 +4,7 @@ import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.t import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; +import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -12,6 +13,7 @@ export type ProviderOrchestrationAdapterInfrastructure = | CodexAdapterV2.CodexAppServerClientFactory | CursorAgentSdk.CursorAgentSdkRunner | CursorSdk.CursorSdk + | CursorKeychain.CursorKeychain | IdAllocator.IdAllocatorV2; /** @@ -24,6 +26,7 @@ export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, CodexAdapterV2.layerAppServerClientFactory, CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)), + CursorKeychain.layer, IdAllocator.layer, ProviderContinuationRequests.layer, ); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 25c7bd090aaf..d8234c41bd90 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -5,7 +5,7 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import { describe, expect, it, vi } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -155,7 +155,7 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("preserves exact argv and strips wrapper-only environment before exec", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; const lease = AcpSessionRuntime.makeLinuxCgroupController().create(); if (lease === undefined) return; const scratchRoot = NodePath.join(process.cwd(), "tmp"); @@ -235,7 +235,7 @@ describe("terminatePosixOwnedProcessTree", () => { ); it("contains a packaged-runtime command without requiring cgroup delegation", () => { - if (HostProcessPlatform.defaultValue() !== "linux") return; + if (HostProcess.Platform.defaultValue() !== "linux") return; const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-wrapper-fake-")); @@ -316,7 +316,7 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; const controller = AcpSessionRuntime.makeLinuxCgroupController(); const lease = controller.create(); if (lease === undefined) return; @@ -531,7 +531,7 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("finds a child forked by a non-leader pthread", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return; + if ((yield* HostProcess.Platform) !== "linux") return; const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-pthread-spawn-")); @@ -655,10 +655,11 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("rotates more than 64 live parents without scanning retained tombstones", () => Effect.gen(function* () { - // Fake PIDs start above the runner's real PID, which the fixture's server entry owns. - const firstFakePid = process.pid + 1; + // The server fixture's process group is the runner's real pid, so a synthetic + // parent sharing it would be skipped as the server's own group. + const parentPidBase = process.pid < 500_000 ? 500_000 : 1_000; const parents = Array.from({ length: 130 }, (_, index) => - identity(firstFakePid + index, 100, firstFakePid + index, firstFakePid + index), + identity(parentPidBase + index, 100, parentPidBase + index, parentPidBase + index), ); let childListReads = 0; let identityCalls = 0; @@ -686,7 +687,7 @@ describe("terminatePosixOwnedProcessTree", () => { const ledger = new Map(); const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; for (let index = 0; index < 5_000; index += 1) { - const tombstonePid = firstFakePid + 100_000 + index; + const tombstonePid = parentPidBase + 100_000 + index; const tombstone = identity(tombstonePid, 1, tombstonePid, tombstonePid); ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { ...tombstone, diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 8f794b59a1af..a1be101b1436 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -5,11 +5,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; -import { - HostProcessExecutablePath, - HostProcessIsExecutable, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -574,8 +570,8 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { expect(profile.browserCommand).not.toContain("/packaged/t3"); expect(yield* fs.exists(profile.acpDirectory)).toBe(true); }).pipe( - Effect.provideService(HostProcessIsExecutable, true), - Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provideService(HostProcess.IsExecutable, true), + Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"), ), ); @@ -601,7 +597,7 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }); } expect(yield* fs.exists(profileDirectory)).toBe(false); - }).pipe(Effect.provideService(HostProcessIsExecutable, true)), + }).pipe(Effect.provideService(HostProcess.IsExecutable, true)), ); it.effect("preflights the no-browser helper and creates private directories only", () => @@ -616,7 +612,7 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile")); expect(yield* fs.exists(profile.acpDirectory)).toBe(true); expect(yield* fs.exists(profile.tokenPath)).toBe(false); - if ((yield* HostProcessPlatform) !== "win32") { + if ((yield* HostProcess.Platform) !== "win32") { expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700); expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700); } diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index c4816709a2f6..a78ba04aa360 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -2,7 +2,7 @@ import * as NodeFSP from "node:fs/promises"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -315,9 +315,14 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const platform = input.platform ?? (yield* HostProcessPlatform); + const platform = input.platform ?? (yield* HostProcess.Platform); const userHome = - input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); + input.userHome ?? + resolveAntigravityUserHome( + platform, + input.baseEnv ?? process.env, + yield* HostProcess.HomeDirectory, + ); const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe( diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 845ffcf137c4..532bd602d145 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -32,7 +32,10 @@ import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; -import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; +import type { + AnyProviderDriver, + ProviderUsageReaderEnv, +} from "@t3tools/provider-core/server/driver"; /** * Union of infrastructure services required to construct any built-in @@ -66,3 +69,21 @@ export const BUILT_IN_DRIVERS: ReadonlyArray + | ProviderUsageReaderEnv + | ProviderUsageReaderEnv + | ProviderUsageReaderEnv + | ProviderUsageReaderEnv + | ProviderUsageReaderEnv; + +/** + * The drivers that keep usage history, in the order the usage page reads + * them: transcript readers first, then scan readers. Aggregation keeps the + * first copy of a duplicate record, so the order is part of the result. + */ +export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< + AnyProviderDriver +> = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver, AntigravityDriver, CursorDriver]; diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 0dcb409ffa3c..a928e64c9fd5 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -111,7 +111,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { ); }); const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), ); expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); @@ -122,11 +122,11 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { Effect.gen(function* () { const { configDir } = yield* writeLogin; const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcess.Platform, "darwin"), Effect.provideService(HttpClient.HttpClient, refuseRequests), ); const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, respond(429, {})), ); expect([darwin, limited]).toEqual([undefined, undefined]); @@ -147,7 +147,7 @@ const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requ version: "2.1.0", ...ids, }).pipe( - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), Effect.result, ); @@ -228,7 +228,7 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { grantId: "grant_a", requestId: "r-1", }).pipe( - Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), Effect.result, Effect.forkChild, diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 5f8379e1c037..e381fa7cabe2 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -7,12 +7,11 @@ * * @module provider/claudeResetCredits */ -import * as NodeOS from "node:os"; import type { ProviderConsumeResetCreditOutcome, ServerProviderResetCredits, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -150,7 +149,7 @@ const readJson = (schema: S, file: string) => const readAccessToken = (configDir: string) => Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "darwin") return undefined; + if ((yield* HostProcess.Platform) === "darwin") return undefined; const path = yield* Path.Path; const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); return credentials.claudeAiOauth?.accessToken?.trim() || undefined; @@ -190,10 +189,14 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( ); /** The CLI keeps the account record beside its settings, or in the home directory by default. */ -export const claudeAccountConfigPath = (configDir: string | undefined) => - Effect.map(Path.Path, (path) => - configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), - ); +export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( + configDir: string | undefined, +) { + const path = yield* Path.Path; + return configDir + ? path.join(configDir, ".claude.json") + : path.join(yield* HostProcess.HomeDirectory, ".claude.json"); +}); const CLAIM_OUTCOMES = { reset: "reset", diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 943dfbb11fe9..6b950ed2d060 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-08T21:30:00Z", + "updatedAt": "2026-10-09T23:31:10Z", "compatibility": [ { "driver": "codex", @@ -668,7 +668,6 @@ "name": "Claude Opus 5.5", "aliases": ["opus", "opus-5.5", "claude-opus-5.5"], "status": "current", - "badge": "new", "profile": "opus-5-5", "adapter": { "claudeCode": { "minVersion": "2.1.280" } } }, @@ -677,7 +676,6 @@ "name": "Claude Sonnet 5.5", "aliases": ["sonnet", "sonnet-5.5", "claude-sonnet-5.5"], "status": "current", - "badge": "new", "profile": "sonnet-5", "adapter": { "claudeCode": { "minVersion": "2.1.284" } } }, diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index ae2064fc85fa..f71f7c1ecfd8 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -5,7 +5,7 @@ import { ProviderInstanceId, type ProviderInstallState, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -215,7 +215,7 @@ describe("provider installation routing", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-install-route-" }); const binary = platform === "win32" ? "agy-test.exe" : "agy-test"; const executable = path.join(directory, binary); diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index d888725b3ba9..ab7257553339 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -134,18 +134,21 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu true, ); const entries = yield* readEntries(input.instanceId, "remove-install"); - const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => { - if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) { - return Effect.succeed([]); - } - const binaryPath = entry.config.binaryPath.trim(); - return resolveCommandPath(binaryPath, { - env: mergeProviderInstanceEnvironment(entry.environment), - }).pipe( - Effect.map((resolved) => [binaryPath, resolved]), - Effect.orElseSucceed(() => [binaryPath]), - ); - }); + const protectedPaths = yield* Effect.forEach( + Object.values(entries), + Effect.fnUntraced(function* (entry) { + if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) { + return []; + } + const binaryPath = entry.config.binaryPath.trim(); + return yield* resolveCommandPath(binaryPath, { + env: yield* mergeProviderInstanceEnvironment(entry.environment), + }).pipe( + Effect.map((resolved) => [binaryPath, resolved]), + Effect.orElseSucceed(() => [binaryPath]), + ); + }), + ); yield* installation .remove(protectedPaths.flat()) .pipe(Effect.mapError(failure(input.instanceId))); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 3b820093542c..26b33a35f4a2 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -20,7 +20,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ProviderRegistry from "./ProviderRegistry.ts"; @@ -45,7 +45,7 @@ const encoder = new TextEncoder(); // `{ command, args }` assertions below hold deterministically on any host // (including Windows). Windows-specific resolution is covered by the dedicated // win32 case at the end of this suite. -const layerNonWindowsPlatform = Layer.succeed(HostProcessPlatform, "linux"); +const layerNonWindowsPlatform = Layer.succeed(HostProcess.Platform, "linux"); function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { if (provider === NATIVE_CLI_DRIVER) { @@ -962,8 +962,8 @@ describe("providerMaintenanceRunner", () => { }).pipe( Effect.provide( Layer.mergeAll( - Layer.succeed(HostProcessPlatform, "win32"), - Layer.succeed(HostProcessEnvironment, { + Layer.succeed(HostProcess.Platform, "win32"), + Layer.succeed(HostProcess.Environment, { PATH: "C:\\fake\\npm", PATHEXT: ".COM;.EXE;.BAT;.CMD", }), diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index 873cc32a2c7f..8271c2f22dc0 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -1,14 +1,14 @@ import { assert, it } from "@effect/vitest"; -import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; -import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; -import * as GitLabCli from "../sourceControl/GitLabCli.ts"; -import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts"; -import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; -import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; -import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts"; +import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; +import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; +import { gitHubProviderFailure } from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; +import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; const cause = new Error("redacted provider failure"); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 316cd1e7ae92..66607a4c8abd 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -3,21 +3,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { SourceControlProviderKind } from "@t3tools/contracts"; -import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; -import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitLabCli from "../sourceControl/GitLabCli.ts"; -import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; -import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; -import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; -import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; -import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; -import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; -import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; -import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; -import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; -import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; -import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; export class PullRequestProviderRegistry extends Context.Service< PullRequestProviderRegistry, @@ -45,28 +32,15 @@ export function fromProviders( * * @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.map( - Effect.all([ - GitHubPullRequestProvider.make, - GitLabPullRequestProvider.make, - ForgejoPullRequestProvider.make, - BitbucketPullRequestProvider.make, - AzureDevOpsPullRequestProvider.make, - ]), - fromProviders, -); +export const make = Effect.gen(function* () { + const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => + driver.make.pipe(Effect.map((instance) => instance.pullRequests)), + ); + return fromProviders( + drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), + ); +}); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( - Layer.provide( - GitHubPullRequestApi.layer.pipe( - Layer.provide( - // Built here too, so a registry built alone still has one budget and pause per host. - GitHubApi.layerWithDependencies, - ), - ), - ), - Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), - Layer.provide(ForgejoCli.layer), - Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), - Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), + Layer.provide(BuiltInDrivers.layer), ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index f9015ecbd9ef..c74faa92f06d 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -29,14 +29,14 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; -import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; -import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import { PullRequestProviderError, type ProviderChangeRequest, type PullRequestProviderApi, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; @@ -463,6 +463,11 @@ function fakeProvider( setReaction: () => Effect.void, listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }), setReviewerRequest: () => Effect.void, + // The hosts' own resolvers, which the service reads instead of the kind. + ...(kind === "github" ? { mergeMessageRewrite: (message: string) => message } : {}), + ...(kind === "azure-devops" + ? { repositoryKey: ({ canonicalKey }: { readonly canonicalKey: string }) => canonicalKey } + : {}), ...overrides, }; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a44bd322adcb..b44d713c3158 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,20 +77,19 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { type ProviderChangeRequest, type ProviderListCursor, type ProviderChangeRequestWatchFingerprint, type PullRequestProviderApi, PullRequestProviderError, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; import * as ViewedFiles from "./pullRequestViewedFiles.ts"; @@ -370,6 +369,12 @@ export interface SupportedProject { * Unique where `repository` is not: Azure's is a bare name that repeats across an organisation. */ readonly remote: string; + /** + * The host's own key for this checkout's repository (`api.repositoryKey`), normalized, or null + * where `owner/name` on the host identifies it. When set, only a reference whose key matches + * is served by this checkout. + */ + readonly repositoryKey: string | null; } /** @@ -557,7 +562,7 @@ function withRateLimitBackoff( ), Effect.flatMap((lease) => effect.pipe( - Effect.provideService(AllowGitHubReserve, allowPaused), + Effect.provideService(SourceControlRateLimit.Interactive, allowPaused), Effect.tap(() => limits.recordSuccess({ ...key, lease })), Effect.tapError((error) => error.reason === "rate-limited" @@ -587,6 +592,10 @@ function withRateLimitBackoff( const wrapped = { kind: api.kind, capabilities: api.capabilities, + ...(api.mergeMessageRewrite === undefined + ? {} + : { mergeMessageRewrite: api.mergeMessageRewrite }), + ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), // Refused during a pause like any other read, except for the caller that asks for the // bypass: a lookup that failed is not held, so letting every background read through would // spawn this host's CLI on each of them and re-extend the pause it was already in. @@ -850,10 +859,9 @@ export const make = Effect.gen(function* () { if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); } - const key = listCursorKey( - host, - kind === "azure-devops" ? identity.canonicalKey : repository, - ); + const repositoryKey = + api?.repositoryKey?.({ canonicalKey: identity.canonicalKey }) ?? null; + const key = listCursorKey(host, repositoryKey ?? repository); if (seen.has(key)) continue; seen.add(key); if (api === null) { @@ -868,10 +876,9 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: - kind === "azure-devops" - ? identity.canonicalKey - : normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: repositoryKey ?? normalizeGitRemoteUrl(`https://${host}/${repository}`), + repositoryKey: + repositoryKey === null ? null : canonicalRepositoryKey(repositoryKey.toLowerCase()), }); } return { supported, unimplemented, viewerRoots }; @@ -920,25 +927,21 @@ export const make = Effect.gen(function* () { const route = supported.find( (candidate) => - candidate.api.kind === "azure-devops" && - candidate.project.repositoryIdentity != null && - canonicalRepositoryKey( - candidate.project.repositoryIdentity.canonicalKey.toLowerCase(), - ) === repositoryKey, + candidate.repositoryKey !== null && candidate.repositoryKey === repositoryKey, ) ?? onHost.find( (candidate) => - candidate.api.kind !== "azure-devops" && + candidate.repositoryKey === null && candidate.repository.toLowerCase() === repository.toLowerCase(), ) ?? - onHost.find((candidate) => candidate.api.kind !== "azure-devops"); + onHost.find((candidate) => candidate.repositoryKey === null); if (route === undefined) { return Effect.fail( new PullRequestUnavailableError({ reason: "provider-unsupported" }), ); } return Effect.succeed( - route.api.kind === "azure-devops" || + route.repositoryKey !== null || route.repository.toLowerCase() === repository.toLowerCase() ? route : { @@ -1558,8 +1561,10 @@ export const make = Effect.gen(function* () { )(function* (input) { const host = input.host.toLowerCase(); const { supported } = yield* listWorkspaceProjects({ host }); - const project = supported.find((candidate) => candidate.api.kind === "github"); - const api = registry.get("github"); + const project = supported.find( + (candidate) => registry.get(candidate.api.kind)?.getRoutingIdentity !== undefined, + ); + const api = project === undefined ? null : registry.get(project.api.kind); if (project === undefined || api?.getRoutingIdentity === undefined) { return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); } @@ -1569,6 +1574,7 @@ export const make = Effect.gen(function* () { host, }) .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); + // Only GitHub reports a routing identity, and the contract names it. return { ...identity, host, provider: "github" as const }; }); @@ -1584,7 +1590,7 @@ export const make = Effect.gen(function* () { detail: "The GitHub account could not be verified before starting the operation.", }); const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); - const api = project.api.kind === "github" ? registry.get("github") : null; + const api = registry.get(project.api.kind); if ( api?.withVerifiedCredential === undefined || input.host?.toLowerCase() !== project.host.toLowerCase() @@ -1605,7 +1611,7 @@ export const make = Effect.gen(function* () { const routing = Effect.fn("PullRequestService.routing")(function* (input: PullRequestRef) { const project = yield* requireProject(input); - const api = project.api.kind === "github" ? registry.get("github") : null; + const api = registry.get(project.api.kind); if (api?.getRoutingIdentity === undefined) { return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); } @@ -2047,7 +2053,7 @@ export const make = Effect.gen(function* () { ); } const mergeSettings = - project.api.kind === "github" && + project.api.mergeMessageRewrite !== undefined && input.stackNumber === undefined && (input.action === "merge" || input.action === "enable-auto-merge") ? serverSettings.getSettings.pipe( @@ -2094,9 +2100,7 @@ export const make = Effect.gen(function* () { ), Effect.mapError(toPullRequestError("runAction")), Effect.as( - project.api.kind === "azure-devops" - ? input.repository.trim() - : project.repository, + project.repositoryKey !== null ? input.repository.trim() : project.repository, ), ), ), diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 1f9aa33ac796..9f64c8352af5 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -16,7 +16,10 @@ import { } from "@t3tools/contracts"; import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; -import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts"; +import type { + ProviderFileRevisions, + PullRequestProviderError, +} from "@t3tools/source-control-core/server/PullRequestProvider"; import type { PullRequestError, SupportedProject } from "./PullRequestService.ts"; /** diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 607172b0f109..c3bb1a1894bc 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -1,6 +1,6 @@ import * as NodeOS from "node:os"; import type { HostResourcesSnapshot } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -40,7 +40,7 @@ function darwinAvailableMemory(output: string): number | null { const make = Effect.fn("makeHostResources")(function* () { const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const sample = Effect.fn("HostResources.sample")(function* () { diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 7032691f0dd0..d53e1a091f47 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -1,9 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - HostProcessArchitecture, - HostProcessEnvironment, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -13,7 +9,7 @@ import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; // The override checks POSIX exec bits on a real file under a linux platform // mock; NTFS never reports those bits, so the check cannot be satisfied there. -const windowsHost = HostProcessPlatform.defaultValue() === "win32"; +const windowsHost = HostProcess.Platform.defaultValue() === "win32"; describe("ResourceMonitorBinary", () => { afterEach(() => { @@ -34,9 +30,9 @@ describe("ResourceMonitorBinary", () => { const service = yield* ResourceMonitorBinary.make().pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService(HostProcessArchitecture, "arm64"), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Architecture, "arm64"), + Effect.provideService(HostProcess.Environment, { T3CODE_RESOURCE_MONITOR_PATH: binaryPath, }), ); @@ -58,10 +54,10 @@ describe("ResourceMonitorBinary", () => { const service = yield* ResourceMonitorBinary.make().pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Architecture, "x64"), Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { T3CODE_RESOURCE_MONITOR_PATH: binaryPath, }), ); @@ -82,9 +78,9 @@ describe("ResourceMonitorBinary", () => { const service = yield* ResourceMonitorBinary.make().pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcessPlatform, "freebsd"), - Effect.provideService(HostProcessArchitecture, "ia32"), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Platform, "freebsd"), + Effect.provideService(HostProcess.Architecture, "ia32"), + Effect.provideService(HostProcess.Environment, { T3CODE_RESOURCE_MONITOR_PATH: binaryPath, }), ); @@ -105,10 +101,10 @@ describe("ResourceMonitorBinary", () => { const service = yield* ResourceMonitorBinary.make().pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Architecture, "x64"), Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "gnu"), - Effect.provideService(HostProcessEnvironment, { + Effect.provideService(HostProcess.Environment, { T3CODE_RESOURCE_MONITOR_PATH: binaryPath, }), ); @@ -127,9 +123,9 @@ describe("ResourceMonitorBinary", () => { }); const service = yield* ResourceMonitorBinary.make().pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcessPlatform, "freebsd"), - Effect.provideService(HostProcessArchitecture, "ia32"), - Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(HostProcess.Platform, "freebsd"), + Effect.provideService(HostProcess.Architecture, "ia32"), + Effect.provideService(HostProcess.Environment, {}), ); const error = yield* Effect.flip(service.resolve); @@ -145,10 +141,10 @@ describe("ResourceMonitorBinary", () => { }); const service = yield* ResourceMonitorBinary.make().pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Architecture, "x64"), Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), - Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(HostProcess.Environment, {}), ); const error = yield* Effect.flip(service.resolve); diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index c4b9d6a85149..035a9364a497 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -1,8 +1,4 @@ -import { - HostProcessArchitecture, - HostProcessEnvironment, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -139,9 +135,9 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; - const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcess.Platform; + const architecture = yield* HostProcess.Architecture; + const environment = yield* HostProcess.Environment; const linuxLibc = platform === "linux" ? yield* ResourceMonitorHostLinuxLibc : undefined; const executableName = binaryName(platform); const platformKey = resourceMonitorPlatformKey(platform, architecture); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 7aee5847001f..d8e27c9d6dd7 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -20,6 +20,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; export class ReviewService extends Context.Service< ReviewService, @@ -73,13 +74,14 @@ export const make = Effect.gen(function* () { const worktreesDirectories = yield* settings.getSettings.pipe( Effect.orElseSucceed(() => ({ worktreesDirectory: "", previousWorktreesDirectories: [] })), ); + const home = yield* HostProcess.HomeDirectory; const [candidate, workspaceRoot, worktreesRoots] = yield* Effect.all([ canonicalizePath(cwd), canonicalizePath(config.cwd), // A managed root that cannot be resolved, or resolves to a filesystem // root through a symlink, is skipped rather than failing every review. Effect.forEach( - managedWorktreesDirectories(worktreesDirectories, config.worktreesDir, path), + managedWorktreesDirectories(worktreesDirectories, config.worktreesDir, path, home), (directory) => canonicalizePath(directory).pipe(Effect.orElseSucceed(() => null)), ).pipe( Effect.map((roots) => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 27cbaca6ec9a..1b7b43e9fd32 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -52,11 +52,7 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; -import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; -import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubApi from "./sourceControl/GitHubApi.ts"; -import * as GitLabCli from "./sourceControl/GitLabCli.ts"; -import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; +import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as VoiceTranscription from "./voice/VoiceTranscription.ts"; import * as RecentThreadMessages from "./voice/RecentThreadMessages.ts"; @@ -108,7 +104,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; -import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as Observability from "./observability/Observability.ts"; @@ -160,7 +156,10 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; -import * as CursorUsageReader from "./usage/cursorUsageReader.ts"; +import * as AntigravityUsage from "./provider/Drivers/AntigravityUsage.ts"; +import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; +import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; +import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; import * as UsageService from "./usage/UsageService.ts"; import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; @@ -258,8 +257,15 @@ const layerBackground = BackgroundPolicy.layer.pipe( ); const layerUsage = UsageService.layer.pipe( + Layer.provide( + Layer.mergeAll( + AntigravityUsage.layer, + CursorUsageAccounts.layer.pipe( + Layer.provide(CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer))), + ), + ).pipe(Layer.provide(ProviderHostLive.layer.pipe(Layer.provide(ServerSecretStore.layer)))), + ), Layer.provide(layerServerSettings), - Layer.provide(CursorUsageReader.layer), ); const layerResourceDiagnostics = Layer.mergeAll( @@ -307,15 +313,7 @@ const layerVoiceTranscription = VoiceTranscription.layer.pipe( const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)); const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubApi.layerWithDependencies, - GitLabCli.layer, - ForgejoCli.layer, - ), - ), + Layer.provideMerge(SourceControlBuiltInDrivers.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(layerVcsDriverRegistry), ); @@ -325,32 +323,21 @@ const layerRepositoryIdentityResolver = Layer.effect( Effect.gen(function* () { const registry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; return yield* RepositoryIdentityResolver.make({ + // Each host that can refine an identity gets a turn; the first one that changes it wins. refine: Effect.fn(function* (identity: RepositoryIdentity) { - const remote = ForgejoCli.parseForgejoRemote(identity.locator.remoteUrl); - if ( - !remote || - !identity.rootPath || - (identity.provider !== undefined && - identity.provider !== "unknown" && - identity.provider !== "forgejo") - ) - return identity; - const handle = yield* registry.resolveHandle({ - cwd: identity.rootPath, - context: { - provider: { kind: "unknown", name: "Unknown", baseUrl: "" }, - remoteName: identity.locator.remoteName, - remoteUrl: identity.locator.remoteUrl, - }, - }); - if (handle.context?.provider.kind !== "forgejo") return identity; - const baseUrl = handle.context.provider.baseUrl.replace(/\/+$/, ""); - const basePath = new URL(baseUrl).pathname.replace(/^\/+|\/+$/g, ""); - const path = - !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`) - ? remote.path.slice(basePath.length + 1) - : remote.path; - return { ...identity, provider: "forgejo", webUrl: `${baseUrl}/${path}` }; + for (const kind of SourceControlBuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS.map( + (driver) => driver.kind, + )) { + const provider = yield* registry.get(kind); + if (provider.refineRepositoryIdentity === undefined) continue; + const refined = yield* provider.refineRepositoryIdentity({ + identity, + resolveContext: (input) => + registry.resolveHandle(input).pipe(Effect.map((handle) => handle.context)), + }); + if (refined !== identity) return refined; + } + return identity; }), }); }), @@ -605,9 +592,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(layerServerSettings), - // The asset route uses the registry's GitHub credential for private PR media. + // The asset route uses the registry's GitHub credential for private PR media, which the + // built-in drivers' layer provides alongside the registry. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubApi.layerWithDependencies), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), @@ -687,9 +674,11 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( // Misc. + // Usage reads provider history through the ProviderHost, which needs the + // background policy below it. + Layer.provideMerge(layerUsage), Layer.provideMerge(layerBackground), Layer.provideMerge(layerResourceDiagnostics), - Layer.provideMerge(layerUsage), Layer.provideMerge(layerVoiceTranscription), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 352bfc60d970..85e14a61bc62 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -11,11 +11,13 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import { GitCommandError } from "@t3tools/contracts"; -import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); @@ -139,6 +141,8 @@ function makeLayer(input: { ); const layer = BitbucketApi.layer.pipe( + Layer.provide(ServerSourceControlHost.layer), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({})), Layer.provide( Layer.succeed( HttpClient.HttpClient, diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts new file mode 100644 index 000000000000..c5c55fd85ab8 --- /dev/null +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -0,0 +1,42 @@ +/** + * The server's implementation of `SourceControlHost.SourceControlHost`, the only server surface + * source control provider packages may use. + * + * @module sourceControl/ServerSourceControlHost + */ +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +export const layer = Layer.effect( + SourceControlHost.SourceControlHost, + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const process = yield* VcsProcess.VcsProcess; + const git = yield* GitVcsDriver.GitVcsDriver; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + return SourceControlHost.SourceControlHost.of({ + settings: { get: serverSettings.getSettings }, + process: { run: process.run }, + git: { + execute: git.execute, + resolveCommit: git.resolveCommit, + remotes: (cwd) => + vcsRegistry.resolve({ cwd }).pipe(Effect.map((handle) => handle.driver.listRemotes(cwd))), + readConfigValue: git.readConfigValue, + resolvePrimaryRemoteName: git.resolvePrimaryRemoteName, + ensureRemote: git.ensureRemote, + listLocalBranchNames: git.listLocalBranchNames, + fetchRemoteBranch: git.fetchRemoteBranch, + fetchRemoteTrackingBranch: git.fetchRemoteTrackingBranch, + setBranchUpstream: git.setBranchUpstream, + switchRef: git.switchRef, + }, + }); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 6101d148a632..b4503fb7535f 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -16,16 +16,21 @@ import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; -import * as BitbucketApi from "./BitbucketApi.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitLabCli from "./GitLabCli.ts"; -import * as ForgejoCli from "./ForgejoCli.ts"; -import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; -import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts"; +import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; +import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; +import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -import { firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; const layerSourceControlProviderRegistryTest = (input: { readonly bitbucket: Partial; @@ -38,14 +43,28 @@ const layerSourceControlProviderRegistryTest = (input: { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), + ServerSourceControlHost.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(VcsProcess.VcsProcess)(input.process), + ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), + ), + ), + ), ), ), ); @@ -67,434 +86,6 @@ const processOutput = ( const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => { - const methods: string[] = []; - const fetchReview = async ( - ...[input, init]: Parameters> - ) => { - const request = new Request(input instanceof Request ? input.url : String(input), { - ...(init?.method === undefined ? {} : { method: init.method }), - ...(init?.headers === undefined ? {} : { headers: init.headers }), - ...(init?.body === undefined ? {} : { body: init.body }), - }); - methods.push(request.method); - if (request.method === "GET") { - assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42"); - return new Response( - encodeJson({ - number: 42, - title: "Review target", - body: "", - html_url: "https://forgejo.test/maria/project/pulls/42", - user: { login: "maria" }, - state: "open", - merged: false, - head: { ref: "feature", sha: "head", repo: null }, - base: { ref: "main", sha: "base", repo: null }, - created_at: "2026-09-13T00:00:00Z", - updated_at: "2026-09-13T00:00:00Z", - closed_at: null, - merged_at: null, - labels: [], - }), - ); - } - assert.strictEqual(request.method, "POST"); - assert.strictEqual( - request.url, - "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews", - ); - assert.deepStrictEqual(JSON.parse(await request.text()), { - event: "COMMENT", - body: "Review summary", - commit_id: "head", - comments: [], - }); - return new Response('{"id":1}', { status: 200 }); - }; - return Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const provider = yield* ForgejoPullRequestProvider.make.pipe( - Effect.provideService(ForgejoCli.ForgejoCli, cli), - ); - yield* provider.submitReview({ - cwd: "/repo", - repository: "maria/project", - host: "forgejo.test", - number: 42, - verdict: "comment", - body: "Review summary", - comments: [], - }); - assert.deepStrictEqual(methods, ["GET", "POST"]); - }).pipe( - Effect.provideService( - FetchHttpClient.Fetch, - Object.assign(fetchReview, { preconnect: () => undefined }), - ), - Effect.provide(FetchHttpClient.layer), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), - ), - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.command === "git") { - assert.deepStrictEqual(input.args, ["remote", "-v"]); - return Effect.succeed( - processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), - ); - } - assert.strictEqual(input.command, "fj"); - assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); - return Effect.succeed(processOutput("")); - }, - }), - ), - ); -}); - -it.effect("reads Forgejo checks without repository or viewer requests", () => { - const paths: string[] = []; - return Effect.gen(function* () { - const provider = yield* ForgejoPullRequestProvider.make; - const read = provider.getChangeRequestChecks; - if (read === undefined) return yield* Effect.die("checks read missing"); - const result = yield* read({ - cwd: "/repo", - repository: "acme/web", - host: "forgejo.test", - number: 1, - }); - assert.strictEqual(result.state, "open"); - assert.strictEqual(result.checks[0]?.status, "failure"); - assert.deepStrictEqual(paths, [ - "repos/acme/web/pulls/1", - "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1", - "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2", - ]); - }).pipe( - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - api: (input) => { - paths.push(input.path); - assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/); - return Effect.succeed( - processOutput( - input.path.endsWith("pulls/1") - ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false, - "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null}, - "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}` - : input.path.endsWith("page=1") - ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]` - : "[]", - ), - ); - }, - }), - ), - ); -}); - -it.effect.each([ - ["Ready", false, true, false, "mergeable"], - ["Draft", true, false, true, "unknown"], - ["Blocked", false, false, false, "unknown"], - ["Unchecked", false, undefined, false, "unknown"], - ["WIP: Legacy draft", undefined, false, true, "unknown"], - ["[WIP] Legacy draft", undefined, false, true, "unknown"], - ["WIP: Explicitly ready", false, false, false, "unknown"], -] as const)( - "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail", - ([title, draft, mergeable, isDraft, mergeability]) => { - const pr = { - number: 42, - title, - body: "", - html_url: "https://forgejo.test/maria/project/pulls/42", - user: { login: "maria" }, - state: "open", - merged: false, - ...(draft === undefined ? {} : { draft }), - ...(mergeable === undefined ? {} : { mergeable }), - head: { ref: "feature", sha: "head", repo: null }, - base: { ref: "main", sha: "base", repo: null }, - created_at: "2026-09-16T00:00:00Z", - updated_at: "2026-09-16T00:00:00Z", - closed_at: null, - merged_at: null, - labels: [], - }; - return Effect.gen(function* () { - const provider = yield* ForgejoPullRequestProvider.make; - const readSummary = provider.getChangeRequestSummary; - if (readSummary === undefined) return yield* Effect.die("summary read missing"); - const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 }; - const list = yield* provider.listChangeRequests({ - ...input, - state: "open", - involvement: "all", - viewer: "maria", - limit: 10, - }); - const summary = yield* readSummary(input); - const detail = yield* provider.getChangeRequest(input); - assert.strictEqual(list.items.length, 1); - for (const result of [list.items[0]!, summary, detail]) { - assert.strictEqual(result.mergeability, mergeability); - assert.strictEqual(result.isDraft, isDraft); - } - }).pipe( - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - api: (input) => { - const [path, query] = input.path.split("?"); - let response: unknown; - switch (path) { - case "repos/maria/project/pulls": - response = new URLSearchParams(query).get("page") === "1" ? [pr] : []; - break; - case "repos/maria/project/pulls/42": - response = pr; - break; - case "repos/maria/project": - response = { full_name: "maria/project", permissions: { push: true, admin: true } }; - break; - case "user": - response = pr.user; - break; - case "repos/maria/project/statuses/head": - response = []; - break; - default: - return Effect.die(`Unexpected Forgejo request: ${input.path}`); - } - return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput)); - }, - }), - ), - ); - }, -); - -it.effect("loads Forgejo pull request references from files and commits views", () => - Effect.gen(function* () { - const provider = yield* ForgejoSourceControlProvider.make; - for (const reference of [ - "42", - "#42", - "https://forgejo.test/maria/project/pulls/42", - "https://forgejo.test/maria/project/pulls/42/", - "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1", - "http://forgejo.test:3000/git/maria/project/pulls/42/commits", - ]) { - const result = yield* provider.getChangeRequest({ cwd: "/repo", reference }); - assert.strictEqual(result.number, 42); - assert.strictEqual(result.title, "Forgejo view reference"); - } - const invalid = yield* provider - .getChangeRequest({ - cwd: "/repo", - reference: "https://forgejo.test/maria/project/pulls/42invalid/files", - }) - .pipe(Effect.result); - assert.strictEqual(invalid._tag, "Failure"); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})), - Layer.mock(VcsProcess.VcsProcess)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ - resolveRepository: () => - Effect.succeed({ - login: "work", - repository: "maria/project", - baseUrl: "https://forgejo.test", - }), - api: (input) => { - assert.strictEqual(input.path, "repos/maria/project/pulls/42"); - return encodeJsonEffect({ - number: 42, - title: "Forgejo view reference", - html_url: "https://forgejo.test/maria/project/pulls/42", - state: "open", - merged: false, - base: { ref: "main", sha: "base", repo: null }, - head: { ref: "feature", sha: "head", repo: null }, - }).pipe(Effect.orDie, Effect.map(processOutput)); - }, - }), - ), - ), - ), -); - -it.effect( - "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations", - () => { - const user = { login: "maria" }; - const review = { - id: 8, - body: "review body", - user, - state: "COMMENT", - submitted_at: "2026-09-12T00:00:00Z", - html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37", - comments_count: 1, - }; - const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at }; - const responses: Record = { - user, - "repos/maria/project/issues/2/comments": [comment], - "repos/maria/project/pulls/2/reviews": [review], - "repos/maria/project/pulls/2/reviews/8": review, - "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" }, - "repos/maria/project/pulls/2/commits": [], - "repos/maria/project/issues/2/reactions": [], - "repos/maria/project/pulls/2/reviews/8/comments": [ - { - ...comment, - id: 38, - body: "inline", - path: "file.ts", - position: 1, - original_position: 1, - commit_id: "head", - original_commit_id: "head", - resolver: null, - }, - ], - "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }], - "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }], - "repos/maria/project/issues/comments/38/reactions": [ - { content: "rocket", user: { login: "reviewer" } }, - ], - }; - const writes: ForgejoCli.ForgejoApiInput[] = []; - let reactionReads = 0; - return Effect.gen(function* () { - const provider = yield* ForgejoPullRequestProvider.make; - const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 }; - const activity = yield* provider.getChangeRequestActivity(input); - assert.deepStrictEqual( - activity.comments.map((entry) => ({ - id: entry.id, - kind: entry.kind, - reactions: entry.reactions, - })), - [ - { - id: "12", - kind: "issue-comment", - reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }], - }, - { - id: "review:8", - kind: "review", - reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }], - }, - { - id: "38", - kind: "review-comment", - reactions: [ - { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false }, - ], - }, - ], - ); - const inlineComment = activity.comments[2]; - assert.ok(inlineComment); - assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]); - for (const reacted of [true, false]) { - yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted }); - yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted }); - } - assert.deepStrictEqual( - writes.map(({ path, method, body }) => ({ path, method, body })), - [ - { - path: "repos/maria/project/issues/comments/37/reactions", - method: "POST", - body: { content: "heart" }, - }, - { - path: "repos/maria/project/issues/comments/38/reactions", - method: "POST", - body: { content: "rocket" }, - }, - { - path: "repos/maria/project/issues/comments/37/reactions", - method: "DELETE", - body: { content: "heart" }, - }, - { - path: "repos/maria/project/issues/comments/38/reactions", - method: "DELETE", - body: { content: "rocket" }, - }, - ], - ); - const missing = yield* provider - .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true }) - .pipe(Effect.result); - assert.strictEqual(missing._tag, "Failure"); - if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID"); - assert.strictEqual(writes.length, 4); - responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from( - { length: 501 }, - (_, index) => ({ - ...comment, - id: 1000 + index, - path: "file.ts", - position: 1, - original_position: 1, - commit_id: "head", - original_commit_id: "head", - resolver: null, - }), - ); - for (let index = 0; index < 500; index++) { - responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = []; - } - reactionReads = 0; - const bounded = yield* provider.getChangeRequestActivity(input); - assert.strictEqual(bounded.reviewThreads.length, 500); - assert.strictEqual( - bounded.comments.filter((entry) => entry.kind === "review-comment").length, - 500, - ); - assert.strictEqual(bounded.commentsTruncated, true); - assert.strictEqual(reactionReads, 502); - }).pipe( - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - api: (input) => { - if (input.method) { - writes.push(input); - return Effect.succeed(processOutput("{}")); - } - const path = input.path.split("?")[0]!; - if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++; - assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`); - const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page")); - return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe( - Effect.orDie, - Effect.map(processOutput), - ); - }, - }), - ), - ); - }, -); - it.effect("reports implemented tools separately from locally available executables", () => { const processMock = { run: (input: VcsProcess.VcsProcessInput) => { @@ -755,820 +346,6 @@ Logged in to gitlab.com as gitlab-user }).pipe(Effect.provide(layerTest)); }); -it.effect("discovers Forgejo accounts and retains the server port", () => - Effect.gen(function* () { - const auth = ForgejoSourceControlProvider.discovery.parseAuth( - processOutput( - yield* encodeJsonEffect([ - { - name: "work", - url: "http://forgejo.local:3000", - ssh_host: "git.forgejo.local", - user: "maria", - default: "true", - valid: "true", - }, - ]), - ), - ); - assert.deepStrictEqual( - firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"), - Option.some("tea version 0.16.0"), - ); - assert.strictEqual(auth.status, "authenticated"); - assert.deepStrictEqual(auth.account, Option.some("maria")); - assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000")); - const revoked = ForgejoSourceControlProvider.discovery.parseAuth( - processOutput( - encodeJson([ - { - name: "work", - url: "http://forgejo.local:3000", - user: "maria", - default: "true", - valid: "false", - }, - ]), - ), - ); - assert.strictEqual(revoked.status, "unauthenticated"); - const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ - cwd: "/repo", - context: { - provider: { - kind: "unknown", - name: "git.forgejo.local", - baseUrl: "https://git.forgejo.local", - }, - remoteName: "origin", - remoteUrl: "git@git.forgejo.local:maria/project.git", - }, - auth: processOutput( - yield* encodeJsonEffect([ - { - name: "work", - url: "http://forgejo.local:3000", - ssh_host: "git.forgejo.local", - user: "maria", - default: "true", - }, - ]), - ), - }); - assert.deepStrictEqual(refined, { - kind: "forgejo", - name: "Forgejo / Gitea", - baseUrl: "http://forgejo.local:3000", - }); - }), -); - -it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () => - Effect.gen(function* () { - const logins = ForgejoCli.parseForgejoLogins( - yield* encodeJsonEffect([ - { - name: "one", - url: "http://forgejo.local:3000", - ssh_host: "forgejo.local", - user: "maria", - default: "true", - }, - { - name: "two", - url: "http://forgejo.local:4000", - ssh_host: "forgejo.local", - user: "maria", - default: "false", - }, - ]), - ); - const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git"); - assert.isNotNull(remote); - assert.deepStrictEqual( - ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"), - remote, - ); - assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!)); - assert.strictEqual( - ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name, - "two", - ); - assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000")); - const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git"); - assert.isNotNull(alias); - assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000")); - const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ - cwd: "/repo", - context: { - provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" }, - remoteName: "origin", - remoteUrl: "git@forgejo.local:maria/project.git", - requestedHost: "forgejo.local:4000", - }, - auth: processOutput(yield* encodeJsonEffect(logins)), - }); - assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000"); - const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git"); - assert.isNotNull(https); - assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two"); - const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000"); - assert.strictEqual( - ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name, - "two", - ); - const mounted = logins.map((login) => ({ - ...login, - url: `http://forgejo.local:4000/${login.name}`, - })); - assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true)); - }), -); - -it.effect("rejects HTTP failures even when tea exits successfully", () => - Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const result = yield* cli - .api({ - cwd: "/repo", - repository: "http://forgejo.local:3000/maria/project", - path: "repos/maria/project/pulls/42", - method: "PATCH", - body: { state: "closed" }, - }) - .pipe(Effect.result); - assert.strictEqual(result._tag, "Failure"); - if (result._tag === "Failure") - assert.strictEqual( - result.failure.detail, - "Forgejo repository or pull request was not found.", - ); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(false), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => { - throw new Error("tea must handle its own HTTP request"); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.args[0] === "api") { - assert.strictEqual(input.stdin, '{"state":"closed"}'); - assert.include(input.args, "work"); - assert.include( - input.args, - "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42", - ); - } - return Effect.succeed( - input.args[0] === "login" - ? processOutput( - encodeJson([ - { - name: "work", - url: "http://forgejo.local:3000", - ssh_host: "forgejo.local", - user: "maria", - default: "true", - }, - ]), - ) - : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }), - ); - }, - }), - ), - ), -); - -it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () => - Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" }); - assert.strictEqual(viewer.stdout, "[]"); - const mountedRepository = yield* cli.resolveRepository({ - cwd: "/upstream-only", - host: "code.test", - repository: "maria/project", - }); - assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo"); - assert.strictEqual(mountedRepository.repository, "maria/project"); - for (const path of [ - "repos/forgejo/maria/project/pulls?state=open", - "repos/forgejo/maria/project", - "repos/reviewer/project/contents/file.ts", - ]) { - const result = yield* cli.api({ - cwd: "/repo", - repository: "forgejo/maria/project", - context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, - remoteName: "origin", - remoteUrl: "https://code.test/forgejo/maria/project.git", - }, - path, - }); - assert.strictEqual(result.stdout, "[]"); - } - const sameOwnerAsMount = yield* cli.resolveRepository({ - cwd: "/repo", - repository: "forgejo/project", - context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, - remoteName: "origin", - remoteUrl: "ssh://git@code.test/forgejo/project.git", - }, - }); - assert.strictEqual(sameOwnerAsMount.command, "tea"); - assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project"); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ - hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => { - throw new Error("tea must handle its own HTTP request"); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.command === "git") - return Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })); - if (input.args[0] === "login") - return Effect.succeed( - processOutput( - encodeJson([ - { - name: "mounted", - url: "https://code.test/forgejo", - ssh_host: "code.test", - user: "maria", - default: "true", - }, - ]), - ), - ); - const supported = [ - "https://code.test/forgejo/api/v1/user", - "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open", - "https://code.test/forgejo/api/v1/repos/maria/project", - "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts", - ]; - if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); - assert.strictEqual(input.command, "tea"); - return Effect.succeed( - supported.includes(input.args.at(-1) ?? "") - ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }) - : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }), - ); - }, - }), - ), - ), -); - -it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { - const commands: string[] = []; - const requests: string[] = []; - return Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - for (const remoteUrl of [ - "http://forgejo.local:3000/maria/project.git", - "ssh://git@ssh.forgejo.local:2222/maria/project.git", - "ssh://git@forgejo.local:2222/maria/project.git", - ]) { - const result = yield* cli.api({ - cwd: "/repo", - repository: "maria/project", - context: { - provider: { - kind: "forgejo", - name: "Forgejo", - baseUrl: "http://forgejo.local:3000", - }, - remoteName: "origin", - remoteUrl, - requestedHost: "forgejo.local:3000", - }, - path: "repos/maria/project/issues/42/comments", - method: "POST", - body: { body: "verified through fj" }, - }); - assert.strictEqual(result.stdout, '{"id":99}'); - } - assert.deepStrictEqual(commands, ["fj"]); - assert.deepStrictEqual(requests, [ - "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", - "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", - "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", - ]); - const viewer = yield* cli.api({ - cwd: "/no-remotes", - host: "forgejo.local:3000", - path: "user", - }); - assert.strictEqual(viewer.stdout, '{"login":"maria"}'); - assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user"); - const upstreamViewer = yield* cli.api({ - cwd: "/upstream-only", - host: "forgejo.local:3000", - path: "user", - }); - assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}'); - assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); - const upstreamRepository = yield* cli.resolveRepository({ - cwd: "/upstream-only", - host: "forgejo.local:3000", - repository: "maria/project", - }); - assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000"); - assert.strictEqual(upstreamRepository.repository, "maria/project"); - const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" }); - assert.strictEqual(httpViewer.stdout, '{"login":"maria"}'); - assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ - hosts: { - "forgejo.local:3000": { type: "Application", token: "test-token" }, - "forgejo.local:4000": { type: "Application", token: "other-token" }, - }, - aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests.push(request.url); - if (request.url.endsWith("/user")) { - assert.strictEqual(request.method, "GET"); - assert.strictEqual(request.headers.authorization, "token test-token"); - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')), - ); - } - assert.strictEqual(request.method, "POST"); - assert.strictEqual(request.headers.authorization, "token test-token"); - assert.strictEqual(request.body._tag, "Uint8Array"); - if (request.body._tag === "Uint8Array") - assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), { - body: "verified through fj", - }); - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })), - ); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - commands.push(input.command); - if (input.command === "git") - return Effect.succeed( - input.cwd === "/no-remotes" - ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }) - : processOutput( - `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`, - ), - ); - assert.strictEqual(input.command, "fj"); - assert.deepStrictEqual(input.args, [ - "--host", - input.cwd === "/no-remotes" - ? "https://forgejo.local:3000" - : "http://forgejo.local:3000", - "whoami", - ]); - return Effect.succeed(processOutput("")); - }, - }), - ), - ); -}); - -it.effect("loads later fj review pages when the server caps pages below the requested size", () => { - const pages: number[] = []; - let issueCommentRequests = 0; - return Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const provider = yield* ForgejoPullRequestProvider.make.pipe( - Effect.provideService(ForgejoCli.ForgejoCli, cli), - ); - const activity = yield* provider.getChangeRequestActivity({ - cwd: "/repo", - repository: "maria/project", - host: "forgejo.test", - number: 42, - }); - assert.strictEqual(activity.commentCount, 42); - assert.strictEqual(activity.comments.at(-1)?.id, "review:41"); - assert.strictEqual(activity.commentsTruncated, false); - assert.deepStrictEqual(pages, [1, 2, 3]); - assert.strictEqual(issueCommentRequests, 1); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - const url = new URL(request.url); - if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") { - issueCommentRequests++; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - encodeJson([ - { - id: 100, - body: "Unpaginated issue comment", - user: { login: "maria" }, - created_at: "2026-09-13T00:00:00Z", - }, - ]), - ), - ), - ); - } - if (url.pathname.endsWith("/reviews")) { - const page = Number(url.searchParams.get("page")); - pages.push(page); - assert.ok(page >= 1 && page <= 3); - const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({ - id: (page - 1) * 20 + index + 1, - body: "Review from a capped page", - user: { login: "maria" }, - state: "COMMENT", - submitted_at: "2026-09-13T00:00:00Z", - comments_count: 0, - })); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(encodeJson(reviews), { - headers: - page === 2 - ? {} - : { - Link: - page === 1 - ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"` - : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`, - }, - }), - ), - ); - } - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])), - ), - ); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - if (input.command === "git") { - assert.deepStrictEqual(input.args, ["remote", "-v"]); - return Effect.succeed( - processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), - ); - } - assert.strictEqual(input.command, "fj"); - assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); - return Effect.succeed(processOutput("")); - }, - }), - ), - ); -}); - -it.effect("falls back to tea when fj is missing or has no account for this server", () => - Effect.gen(function* () { - for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) { - const commands: string[] = []; - yield* Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const result = yield* cli.api({ - cwd: "/repo", - repository: "https://forgejo.local:3000/maria/project", - path: "repos/maria/project/pulls", - }); - assert.strictEqual(result.stdout, "[]"); - assert.deepStrictEqual( - commands, - scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"], - ); - const viewer = yield* cli.api({ - cwd: "/upstream-only", - host: "forgejo.local:3000", - path: "user", - }); - assert.strictEqual(viewer.stdout, "[]"); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - scenario === "stale-invalid-storage" - ? "invalid json" - : encodeJson({ - hosts: { - [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: { - type: "Application", - token: "test-token", - }, - }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => { - throw new Error("tea must handle its own HTTP request"); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - commands.push(input.command); - if (input.command === "git") - return Effect.succeed( - processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }), - ); - if (input.command === "fj") - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error("fj not found"), - }), - ); - assert.strictEqual(input.command, "tea"); - if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); - return Effect.succeed( - input.args[0] === "login" - ? processOutput( - encodeJson([ - { - name: "work", - url: "https://forgejo.local:3000", - user: "maria", - default: "true", - valid: "true", - }, - ]), - ) - : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }), - ); - }, - }), - ), - ); - } - }), -); - -it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () => - Effect.gen(function* () { - for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) { - let writes = 0; - yield* Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const result = yield* cli - .api({ - cwd: "/repo", - repository: "https://forgejo.local/maria/project", - path: "repos/maria/project/issues/42/comments", - method: "POST", - body: { body: "only once" }, - }) - .pipe(Effect.result); - assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure"); - if (result._tag === "Success") assert.strictEqual(result.success.stdout, ""); - if (result._tag === "Failure") { - assert.strictEqual(result.failure.command, "fj"); - assert.strictEqual(result.failure.httpStatus, status); - } - assert.strictEqual(writes, 1); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ - hosts: { - "forgejo.local": { type: "Application", token: "test-token" }, - }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - writes++; - assert.strictEqual( - request.url, - "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments", - ); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(status < 300 ? null : "", { - status, - headers: { location: "https://other.local/" }, - }), - ), - ); - }), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - assert.strictEqual( - input.command, - "fj", - "a failed mutation must never switch accounts or CLI", - ); - return Effect.succeed(processOutput("")); - }, - }), - ), - ); - } - }), -); - -it.effect( - "discovers fj first and retains configured authentication failures instead of switching accounts", - () => - Effect.gen(function* () { - for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) { - const commands: string[] = []; - yield* Effect.gen(function* () { - const spec = yield* ForgejoSourceControlProvider.makeDiscovery; - assert.strictEqual(spec.type, "managed-cli"); - if (spec.type !== "managed-cli") return; - const result = yield* spec.probe("/repo"); - assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj"); - assert.strictEqual( - result.auth.status, - scenario === "revoked" - ? "unauthenticated" - : scenario === "invalid-storage" - ? "unknown" - : "authenticated", - ); - if (scenario !== "revoked" && scenario !== "invalid-storage") - assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000")); - assert.deepStrictEqual( - result.auth.account, - scenario === "authenticated" || scenario === "missing" - ? Option.some("maria") - : Option.none(), - ); - assert.strictEqual( - commands.some((command) => command.startsWith("tea ")), - scenario === "missing", - ); - assert.include(commands, "fj version"); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ForgejoCli.ForgejoCli)({ - getAccount: (input) => { - assert.strictEqual(scenario, "authenticated"); - assert.deepStrictEqual(input, { - cwd: "/repo", - baseUrl: "http://forgejo.local:3000", - }); - return Effect.succeed("maria"); - }, - listLogins: (input) => { - assert.strictEqual( - input.remoteUrl, - "http://forgejo.local:3000/maria/project.git", - ); - if (scenario === "invalid-storage") - return Effect.fail( - new ForgejoCli.ForgejoCliError({ - command: "fj", - cwd: input.cwd, - reason: "authentication", - detail: "fj authentication storage is invalid.", - }), - ); - return Effect.succeed([ - { - name: "forgejo.local:3000", - url: "http://forgejo.local:3000", - user: "", - default: "false", - }, - ]); - }, - }), - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => { - commands.push(`${input.command} ${input.args.join(" ")}`); - if (input.command === "git") - return Effect.succeed( - processOutput("http://forgejo.local:3000/maria/project.git\n"), - ); - if (input.command === "fj") { - if (scenario === "missing") - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error("fj not found"), - }), - ); - if (input.args[0] === "version") - return Effect.succeed(processOutput("fj 0.10.0")); - if (scenario === "invalid-storage") { - assert.deepStrictEqual(input.args, ["auth", "list"]); - return Effect.succeed(processOutput("")); - } - assert.deepStrictEqual(input.args, [ - "--host", - "http://forgejo.local:3000", - "whoami", - ]); - return Effect.succeed( - processOutput("", { - exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0), - }), - ); - } - assert.strictEqual(input.command, "tea"); - return Effect.succeed( - input.args[0] === "--version" - ? processOutput("tea version 0.16.0") - : processOutput( - encodeJson([ - { - name: "work", - url: "http://forgejo.local:3000", - user: "maria", - default: "true", - valid: "true", - }, - ]), - ), - ); - }, - }), - ), - ), - ); - } - }), -); - it.effect( "checks out fj pull refs and preserves existing branches and dirty files until forced", () => @@ -1618,22 +395,27 @@ it.effect( args: ["rev-parse", "HEAD"], })).stdout.trim(); const fetched: string[] = []; + const host = yield* SourceControlHost.SourceControlHost; const provider = yield* ForgejoSourceControlProvider.make.pipe( Effect.provideService( - VcsProcess.VcsProcess, - VcsProcess.VcsProcess.of({ - run: (input) => { - if (input.args[0] !== "fetch") return git.run(input); - const url = input.args[2]; - assert.isDefined(url); - fetched.push(url!); - // Only SSH transport is substituted; both paths fetch the real pull ref. - return git.run({ - ...input, - args: input.args.map((arg) => - arg === "git@forgejo.test:reviewer/project.git" ? source : arg, - ), - }); + SourceControlHost.SourceControlHost, + SourceControlHost.SourceControlHost.of({ + settings: host.settings, + git: host.git, + process: { + run: (input) => { + if (input.args[0] !== "fetch") return git.run(input); + const url = input.args[2]; + assert.isDefined(url); + fetched.push(url!); + // Only SSH transport is substituted; both paths fetch the real pull ref. + return git.run({ + ...input, + args: input.args.map((arg) => + arg === "git@forgejo.test:reviewer/project.git" ? source : arg, + ), + }); + }, }, }), ), @@ -1750,6 +532,14 @@ it.effect( assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]); }).pipe( Effect.scoped, - Effect.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Effect.provide( + ServerSourceControlHost.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), + Layer.provideMerge(NodeServices.layer), + ), + ), ), ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index e178148f131f..f56985fefd63 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; +import { detailFromCause, firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; interface DiscoveryProbe { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 705c38b847b7..bfed745ca295 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -13,14 +13,19 @@ import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; -import * as BitbucketApi from "./BitbucketApi.ts"; +import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; +import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitLabCli from "./GitLabCli.ts"; -import * as ForgejoCli from "./ForgejoCli.ts"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -96,9 +101,22 @@ function makeRegistry(input: { NodeServices.layer, layerRegistry, layerProcess, + ServerSourceControlHost.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProcess, + layerRegistry, + ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + ), + ), + ), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), Layer.mock(GitHubApi.GitHubApi)({ // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), @@ -106,6 +124,7 @@ function makeRegistry(input: { }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 7a626b13f50f..7af90663dc93 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -11,20 +11,17 @@ import { import type { SourceControlProviderKind } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; -import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; -import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as BuiltInDrivers from "./builtInDrivers.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { probeSourceControlProvider, refineUnknownRemoteProvider, type SourceControlProviderDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +} from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); @@ -161,8 +158,7 @@ function bindProviderContext( } return SourceControlProvider.SourceControlProvider.of({ - kind: provider.kind, - ...(provider.resolveLink ? { resolveLink: provider.resolveLink } : {}), + ...provider, listChangeRequests: (input) => provider.listChangeRequests({ ...input, @@ -201,7 +197,7 @@ function bindProviderContext( export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const config = yield* ServerConfig.ServerConfig; - const process = yield* VcsProcess.VcsProcess; + const { process } = yield* SourceControlHost.SourceControlHost; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const providers = new Map< SourceControlProviderKind, @@ -304,37 +300,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); export const make = Effect.gen(function* () { - const github = yield* GitHubSourceControlProvider.make; - const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; - const gitlab = yield* GitLabSourceControlProvider.make; - const forgejo = yield* ForgejoSourceControlProvider.make; - const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery; - const bitbucket = yield* BitbucketSourceControlProvider.make; - const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; - const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; - return yield* makeWithProviders([ - { - kind: "github", - provider: github, - discovery: githubDiscovery, - }, - { - kind: "gitlab", - provider: gitlab, - discovery: GitLabSourceControlProvider.discovery, - }, - { - kind: "azure-devops", - provider: azureDevOps, - discovery: AzureDevOpsSourceControlProvider.discovery, - }, - { - kind: "bitbucket", - provider: bitbucket, - discovery: bitbucketDiscovery, - }, - { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery }, - ]); + const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => + driver.make.pipe( + Effect.map((instance): SourceControlProviderRegistration => ({ + kind: driver.kind, + provider: instance.sourceControl, + discovery: instance.discovery, + })), + ), + ); + return yield* makeWithProviders(drivers); }); export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 3495b2270b3e..c051ab47f42f 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -13,9 +13,9 @@ import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts" import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "./BitbucketApi.ts"; -import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketSourceControlProvider from "@t3tools/source-control-bitbucket/server/BitbucketSourceControlProvider"; +import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 4a4d1fec6434..d17f860050bb 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -27,8 +27,9 @@ import { type GitCloneProgressLine, } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); @@ -213,7 +214,7 @@ export const make = Effect.gen(function* () { }); } - return path.resolve(expandHomePath(trimmed)); + return path.resolve(expandHomePath(trimmed, yield* HostProcess.HomeDirectory)); }, ); diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts new file mode 100644 index 000000000000..2e830dec803e --- /dev/null +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -0,0 +1,55 @@ +/** + * The source control drivers this build ships with. Both registries, repository operations and + * pull requests, iterate this list; a host with no driver here shows up as unsupported. + * + * Adding a host means writing its `@t3tools/source-control-` package, adding its driver + * here, and providing its services' layers in `layer` below. + * + * @module sourceControl/builtInDrivers + */ +import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; +import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; +import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; +import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Layer from "effect/Layer"; + +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; + +const drivers = [ + GitHubDriver.driver, + GitLabDriver.driver, + AzureDevOpsDriver.driver, + BitbucketDriver.driver, + ForgejoDriver.driver, +]; + +/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ +export type BuiltInSourceControlDriversEnv = + (typeof drivers)[number] extends SourceControlDriver ? R : never; + +/** Ordered as the hosts appear in discovery. */ +export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< + SourceControlDriver +> = drivers; + +/** The services the built-in drivers' packages own, plus the host port they all run against. */ +export const layer = Layer.mergeAll( + // `GitHubApi.layerWithDependencies` carries the quota reserve and rate-limit pause every GitHub + // reader shares, so the server builds it once, here. + GitHubPullRequestApi.layer.pipe(Layer.provideMerge(GitHubApi.layerWithDependencies)), + AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), + BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), + ForgejoCli.layer, + GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), +).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/server/src/sourceControl/PrTemplateDetection.test.ts b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts similarity index 97% rename from apps/server/src/sourceControl/PrTemplateDetection.test.ts rename to apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts index 53972f019f24..1c7c34d2ab80 100644 --- a/apps/server/src/sourceControl/PrTemplateDetection.test.ts +++ b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts @@ -9,7 +9,7 @@ import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { detectPrTemplate } from "./PrTemplateDetection.ts"; +import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const SINGLE_TEMPLATE_PATHS = [ @@ -80,7 +80,7 @@ const commitTemplates = (cwd: string) => const detectTemplate = (cwd: string, treeish = "HEAD") => Effect.gen(function* () { const git = yield* GitVcsDriver.GitVcsDriver; - return yield* detectPrTemplate(cwd, treeish, git.execute); + return yield* GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute); }); it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => diff --git a/apps/server/src/pullRequest/githubStackRebase.test.ts b/apps/server/src/sourceControl/githubStackRebase.test.ts similarity index 88% rename from apps/server/src/pullRequest/githubStackRebase.test.ts rename to apps/server/src/sourceControl/githubStackRebase.test.ts index 26b801d13bd8..613c0016c65f 100644 --- a/apps/server/src/pullRequest/githubStackRebase.test.ts +++ b/apps/server/src/sourceControl/githubStackRebase.test.ts @@ -5,15 +5,26 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import { cascadeRebaseStack } from "@t3tools/source-control-github/server/githubStackRebase"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { cascadeRebaseStack } from "./githubStackRebase.ts"; +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; const layer = Layer.mergeAll( Layer.mock(GitHubApi.GitHubApi)({ credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), }), - VcsProcess.layer, + // The cascade runs real git through the server's host; the git driver itself is never asked. + ServerSourceControlHost.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), + Layer.provideMerge(VcsProcess.layer), + ), ).pipe(Layer.provideMerge(NodeServices.layer)); /** diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d823e84e45dd..7804ed42ef10 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -38,6 +38,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCleanupThread = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJson), @@ -213,6 +214,7 @@ export const make = Effect.gen(function* () { serverSettings, config.worktreesDir, path, + yield* HostProcess.HomeDirectory, )) { // An unmounted drive only skips its own worktrees. const root = yield* fs.exists(directory).pipe( diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index e0a4786ece6f..73f844e4017c 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -11,7 +11,7 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpServer from "effect/http/HttpServer"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; import * as HttpServerResponse from "effect/http/HttpServerResponse"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; @@ -105,8 +105,8 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ), Layer.provide( Layer.mergeAll( - Layer.succeed(HostProcessPlatform, "win32"), - Layer.succeed(HostProcessArchitecture, "x64"), + Layer.succeed(HostProcess.Platform, "win32"), + Layer.succeed(HostProcess.Architecture, "x64"), layerAcceptThenFailClient(batches), ), ), @@ -167,8 +167,8 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { Layer.provide(layerConfig), Layer.provide( Layer.mergeAll( - Layer.succeed(HostProcessPlatform, "linux"), - Layer.succeed(HostProcessArchitecture, "arm64"), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Architecture, "arm64"), ), ), Layer.provideMerge(NodeHttpServer.layerTest), @@ -257,8 +257,8 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { Layer.provide(layerConfig), Layer.provide( Layer.mergeAll( - Layer.succeed(HostProcessPlatform, "linux"), - Layer.succeed(HostProcessArchitecture, "arm64"), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Architecture, "arm64"), ), ), Layer.provideMerge(NodeHttpServer.layerTest), diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 3d10e93a26d6..864979759588 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -8,7 +8,7 @@ * * @module AnalyticsService */ -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import type { ClientOs } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; @@ -136,8 +136,8 @@ export const make = Effect.gen(function* () { // The background flush and the shutdown flush must not send the same batch at once. const flushLock = yield* Semaphore.make(1); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; + const hostPlatform = yield* HostProcess.Platform; + const hostArchitecture = yield* HostProcess.Architecture; const enqueueBufferedEvent = ( uuid: string, diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 4009625d5752..6704300f87a4 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,4 +1,3 @@ -import * as NodeOS from "node:os"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -10,6 +9,7 @@ import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; /** * Codex writes `tokens` only for ChatGPT logins and omits the key for API-key, @@ -311,6 +311,7 @@ export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierFo Effect.orElseSucceed(() => null), ); -export const getTelemetryIdentifier = Effect.suspend(() => - getTelemetryIdentifierForHome(NodeOS.homedir()), +export const getTelemetryIdentifier = Effect.flatMap( + HostProcess.HomeDirectory, + getTelemetryIdentifierForHome, ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5a40f7bbb933..9889f33d4a8f 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -12,7 +12,7 @@ import { ServerSettingsError, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; -import { HostProcessPlatform, HostProcessArchitecture } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Data from "effect/Data"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; @@ -315,7 +315,7 @@ const createManager = ( ); const layerWithHostPlatform = (platform: NodeJS.Platform) => - Layer.succeed(HostProcessPlatform, platform); + Layer.succeed(HostProcess.Platform, platform); // Apply the existing line policy, then find the longest code-point-aligned byte tail. function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): string { @@ -619,7 +619,7 @@ it.layer( it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; + if ((yield* HostProcess.Platform) === "win32") return; const path = yield* Path.Path; @@ -1826,7 +1826,7 @@ it.layer( it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const missingShell = platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { @@ -1933,7 +1933,7 @@ it.layer( Effect.provide( Layer.merge( layerWithHostPlatform("win32"), - Layer.succeed(HostProcessArchitecture, "x64"), + Layer.succeed(HostProcess.Architecture, "x64"), ), ), ); @@ -2513,7 +2513,7 @@ it.layer( it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; + if ((yield* HostProcess.Platform) === "win32") return; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => "/bin/zsh", }); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 8aa64408993e..d8b2cf3e0553 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -42,7 +42,7 @@ import { } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { mergePathEntries } from "@t3tools/shared/shell"; import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server"; @@ -1317,6 +1317,7 @@ function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv: Record | null | undefined, platform: NodeJS.Platform, + home: string, ): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { @@ -1331,7 +1332,7 @@ function createTerminalSpawnEnv( ? Object.keys(spawnEnv).find((candidate) => candidate.toLowerCase() === key.toLowerCase()) : undefined; spawnEnv[existingKey ?? key] = - key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; + key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value, home) : value; } } // An explicit empty override opts out for terminals started without a client. @@ -1408,7 +1409,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); } - let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + let resolved = yield* mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); if (instance.driver === "codex") { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isSome(config)) { @@ -1477,8 +1478,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; + const platform = yield* HostProcess.Platform; + const architecture = yield* HostProcess.Architecture; // Terminals must inherit the user's full environment (minus the blocklist // applied in createTerminalSpawnEnv) — an allowlist here silently strips // things like PSModulePath, DISPLAY, proxies, and toolchain variables. @@ -2244,7 +2245,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.andThen( Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv, platform); + const terminalEnv = createTerminalSpawnEnv( + baseEnv, + session.runtimeEnv, + platform, + yield* HostProcess.HomeDirectory, + ); // Append (never prepend) managed ACP agent install directories so // `kimi login` and friends resolve by name without shadowing any // system or user tool of the same name. diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index e8c2e4d75ff7..79f73c2e9060 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -3,7 +3,7 @@ import * as NodeNet from "node:net"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -66,8 +66,8 @@ const layerTestFor = (platform: NodeJS.Platform = "win32") => Layer.provide( Layer.mergeAll( NodeServices.layer, - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessArchitecture, "x64"), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.Architecture, "x64"), Layer.succeed(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.resolve(fakeNodePty)), ), ), @@ -272,8 +272,8 @@ it.effect("reports native module load failures as structured startup defects", ( Effect.provide( Layer.mergeAll( NodeServices.layer, - Layer.succeed(HostProcessPlatform, "win32"), - Layer.succeed(HostProcessArchitecture, "x64"), + Layer.succeed(HostProcess.Platform, "win32"), + Layer.succeed(HostProcess.Architecture, "x64"), ), ), ), diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index ed4aee84006e..e865c3020a80 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -7,7 +7,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -46,8 +46,8 @@ let didEnsureSpawnHelperExecutable = false; const resolveNodePtySpawnHelperPath = Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; + const platform = yield* HostProcess.Platform; + const architecture = yield* HostProcess.Architecture; const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); const packageDir = path.dirname(packageJsonPath); @@ -67,7 +67,7 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; if (platform === "win32") return; if (didEnsureSpawnHelperExecutable) return; @@ -236,8 +236,8 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* () { const loadNodePtyModule = yield* NodePtyModuleLoaderRef; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; + const platform = yield* HostProcess.Platform; + const architecture = yield* HostProcess.Architecture; const nodePty = yield* Effect.tryPromise({ try: loadNodePtyModule, @@ -253,8 +253,8 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* () { ensureNodePtySpawnHelperExecutable().pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HostProcessArchitecture, architecture), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Architecture, architecture), Effect.orElseSucceed(() => undefined), ), ); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 29c0f950f7c8..577c122f65e0 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -35,7 +35,7 @@ function makeFakeClaudeBinary(dir: string) { return Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; const binDir = path.join(dir, "bin"); const fixturePath = yield* path.fromFileUrl( new URL("./testing/ClaudeTextGeneration.fixture.mjs", import.meta.url), @@ -69,7 +69,7 @@ function withFakeClaudeEnv( const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); const binDir = yield* makeFakeClaudeBinary(tempDir); - const pathDelimiter = (yield* isHostWindows) ? ";" : ":"; + const pathDelimiter = (yield* HostProcess.isWindows) ? ";" : ":"; const previousPath = process.env.PATH; const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0477c7fa163b..3902b0c74d78 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -26,6 +26,7 @@ import { } from "@t3tools/provider-core/server/textGenerationUtils"; import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const CODEX_TIMEOUT_MS = 180_000; const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -214,7 +215,12 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func env: { ...effectiveEnvironment, ...(effectiveConfig.homePath - ? { CODEX_HOME: expandHomePath(effectiveConfig.homePath) } + ? { + CODEX_HOME: expandHomePath( + effectiveConfig.homePath, + yield* HostProcess.HomeDirectory, + ), + } : {}), }, cwd, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 52e98f0eaf7d..cb9270a7ed3b 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -8,7 +8,7 @@ import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { EnvironmentId, @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Context from "effect/Context"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; @@ -32,8 +33,14 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; -import * as CursorUsageReader from "./cursorUsageReader.ts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; +import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; +import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts"; +import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; +import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import * as UsageService from "./UsageService.ts"; const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -87,6 +94,27 @@ const setup = Effect.gen(function* () { }; }); +const layerCursorUsageAccounts = ( + reader: Layer.Layer, +) => Layer.fresh(CursorUsageAccounts.layer).pipe(Layer.provide(reader)); + +/** + * A service with its own Cursor account caches over `read`, as a fresh server + * process has. `awaitPersisted` also waits for the account cache writes. + */ +const makeWithCursor = (read: CursorAccountReader.CursorAccountReader["Service"]["read"]) => + Effect.gen(function* () { + const context = yield* Layer.build( + layerCursorUsageAccounts(Layer.succeed(CursorAccountReader.CursorAccountReader, { read })), + ); + const accounts = Context.get(context, CursorUsageAccounts.CursorUsageAccounts); + const service = yield* UsageService.make.pipe(Effect.provideContext(context)); + return { + ...service, + awaitPersisted: Effect.andThen(service.awaitPersisted, accounts.awaitPersisted), + }; + }); + const layerService = (input: { readonly prefix: string; readonly home: string; @@ -97,10 +125,16 @@ const layerService = (input: { readonly environment?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; }) => - ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + layerCursorUsageAccounts( + CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), + ).pipe( + Layer.provideMerge(AntigravityUsage.layer), + Layer.provideMerge(ProviderHostLive.layer), + Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), + Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })), Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(CursorUsageReader.layer), - Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), + Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -116,7 +150,7 @@ const layerService = (input: { ), ), Layer.provideMerge( - Layer.succeed(HostProcessEnvironment, { + Layer.succeed(HostProcess.Environment, { HOME: input.home, GROK_HOME: NodePath.join(input.home, "grok"), OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), @@ -364,9 +398,7 @@ describe("UsageService", () => { const gate = yield* Deferred.make(); cursor.state.gate = gate; yield* Effect.gen(function* () { - const service = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), - ); + const service = yield* makeWithCursor(cursor.read); // Cold: nothing cached yet, so Cursor answers empty while it refreshes. const cold = yield* service.readSummary(WINDOW); assert.strictEqual(cursorSource(cold)?.refreshing, true); @@ -421,9 +453,7 @@ describe("UsageService", () => { { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, ]; yield* Effect.gen(function* () { - const service = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), - ); + const service = yield* makeWithCursor(cursor.read); const read = (input: UsageSummaryInput) => service.readSummary({ ...input, awaitRefresh: true }); assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19); @@ -470,9 +500,7 @@ describe("UsageService", () => { const cursor = makeFakeCursor(); cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }]; yield* Effect.gen(function* () { - const service = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), - ); + const service = yield* makeWithCursor(cursor.read); yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); yield* TestClock.adjust(Duration.minutes(2)); @@ -513,17 +541,13 @@ describe("UsageService", () => { { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }, ]; yield* Effect.gen(function* () { - const first = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: before.read }), - ); + const first = yield* makeWithCursor(before.read); const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true }); yield* first.awaitPersisted; const after = makeFakeCursor(); after.state.events = before.state.events; - const restarted = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: after.read }), - ); + const restarted = yield* makeWithCursor(after.read); const restored = yield* restarted.readSummary(WINDOW); assert.isUndefined(cursorSource(restored)?.refreshing); assert.deepStrictEqual(restored.buckets, original.buckets); @@ -805,6 +829,8 @@ describe("UsageService", () => { }, [ProviderInstanceId.make("grok-work")]: { driver: ProviderDriverKind.make("grok"), + // An undecodable config must not hide history Grok reads by home alone. + config: { customModels: "not-a-list" }, environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], }, }, @@ -1149,7 +1175,8 @@ describe("UsageService", () => { return text; }); - const summary = yield* (yield* UsageService.make).readSummary(WINDOW); + const restarted = yield* UsageService.make; + const summary = yield* restarted.readSummary(WINDOW); // The live rollout re-parses at the ultrafast rate (10 x 6); the // deleted one keeps its saved v4 usage at the standard rate (20 x 1). assert.strictEqual(totalOutputTokens(summary), 30); @@ -1162,6 +1189,9 @@ describe("UsageService", () => { yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, ); + // The migrated cache is written in the background; let it land before + // the layer removes the state directory under it. + yield* restarted.awaitPersisted; }).pipe( Effect.provide( layerService({ @@ -1326,7 +1356,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( "keeps a newer cached read when a slower scan of another window finishes later", () => Effect.gen(function* () { @@ -1357,7 +1387,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( "keeps the later read when a scan that read earlier finishes first", () => Effect.gen(function* () { diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 76bdf21f377a..3b496d8f4498 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,28 +1,25 @@ /** - * UsageService - scans provider transcripts and returns priced usage buckets. + * UsageService - reads every driver's usage history and returns priced usage + * buckets. * - * The scan reads native session files and databases, including work driven - * outside T3 Code. Cursor's local records provide only partial coverage. + * Each built-in driver with a `usage` reader contributes its sources, including + * work driven outside T3 Code. A `transcripts` reader names JSONL directories + * that this service streams itself; a `scan` reader reads its own sources. * * JSONL transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm * scans only reparse files that changed, and a file that merely grew resumes * from its cached parse position so only the appended bytes are read. - * OpenCode's SQLite reader queries the live database each scan so WAL writes - * remain visible. Antigravity databases are memoised in memory while the - * database and its WAL keep the same `(size, mtime, ctime)`. * - * Cursor's account API is slow, so its source answers from a cache and marks - * itself `refreshing` while a background refresh runs; `awaitRefresh` waits - * for that refresh instead. See `cursorAccountCache`. + * A scan reader with a slow source (an account API) may answer from its own + * cache and mark itself `refreshing` while a background refresh runs; + * `awaitRefresh` waits for that refresh instead. * * @module UsageService */ import * as NodeOS from "node:os"; import { - ClaudeSettings, - CodexSettings, type ProviderInstanceConfig, ProviderInstanceId, USAGE_CONTRACT_VERSION, @@ -34,11 +31,10 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; 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 DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -54,25 +50,15 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; -import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; -import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; -import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts"; +import { BUILT_IN_USAGE_DRIVERS, type BuiltInUsageReadersEnv } from "../provider/builtInDrivers.ts"; +import type { ProviderDriver } from "@t3tools/provider-core/server/driver"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; -import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; -import { - CURSOR_ACCOUNT_CACHE_FILE_NAME, - CURSOR_ACCOUNT_TTL_MS, - cursorFetchRange, - decodeCursorAccountCaches, - encodeCursorAccountCaches, - isCursorCacheFresh, - mergeCursorFetch, - type CursorAccountCache, - type CursorCredentialSource, -} from "./cursorAccountCache.ts"; -import * as CursorUsageReader from "./cursorUsageReader.ts"; +import type { + ProviderUsageInstance, + TranscriptUsageFormat, + UsageRecord, +} from "@t3tools/provider-core/server/usage"; import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -90,7 +76,6 @@ import { type CachedFile, type ScanCache, } from "./usageScanCache.ts"; -import type { UsageRecord } from "./usageTranscripts.ts"; const LITELLM_RATES_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; @@ -114,13 +99,32 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; */ const CACHE_RETENTION_DAYS = 92; -const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; - /** Transcripts parsed at once. More gains little once the disk stays busy. */ const TRANSCRIPT_READ_CONCURRENCY = 4; -const decodeCodexSettings = Schema.decodeOption(CodexSettings); -const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); +/** The transcript readers, in driver order. */ +const transcriptReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => + driver.usage?.kind === "transcripts" ? [{ driver, reader: driver.usage }] : [], +); + +/** The scan readers, in driver order. */ +const scanReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => + driver.usage?.kind === "scan" ? [{ driver, reader: driver.usage }] : [], +); + +/** Transcript formats by provider, for decoding the persisted scan cache. */ +const transcriptFormats = new Map( + transcriptReaders.map(({ reader }) => [reader.provider, reader.format] as const), +); + +/** One transcript directory to scan. */ +interface TranscriptSource { + readonly provider: UsageProviderKind; + readonly format: TranscriptUsageFormat; + readonly dir: string; + readonly volumeId: string; + readonly fileName?: string; +} /** On-disk shape of the rate snapshot. */ const RatesCacheFile = Schema.Struct({ @@ -145,13 +149,21 @@ function isLaterRead(a: CachedFile, b: CachedFile): boolean { return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); } +/** Providers whose format lets one session's records appear in several files. */ +const sharedSessionProviders: ReadonlySet = new Set( + transcriptReaders.flatMap(({ reader }) => + reader.format.sharedSessionsAcrossFiles ? [reader.provider] : [], + ), +); + /** - * Codex sessions with records in more than one file, such as a rollout that - * moved after it was read. Only these need cross-file dedupe keys: within one - * file the occurrence count already keeps every key unique, so keying the rest - * would only build and hash a string for each of their records. + * Sessions of `sharedSessionProviders` with records in more than one file, + * such as a rollout that moved after it was read. Only these need cross-file + * dedupe keys: within one file the occurrence count already keeps every key + * unique, so keying the rest would only build and hash a string for each of + * their records. */ -function sharedCodexSessions( +function sharedSessions( files: readonly { readonly records: readonly UsageRecord[] }[], ): ReadonlySet { const firstFile = new Map(); @@ -159,7 +171,8 @@ function sharedCodexSessions( for (const [index, file] of files.entries()) { let previous = ""; for (const { provider, sessionId } of file.records) { - if (provider !== "codex" || sessionId === previous || sessionId.length === 0) continue; + if (!sharedSessionProviders.has(provider) || sessionId === previous || sessionId.length === 0) + continue; previous = sessionId; const first = firstFile.get(sessionId); if (first === undefined) firstFile.set(sessionId, index); @@ -209,34 +222,18 @@ const layerTest = Layer.succeed( ); export const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; - const hostEnvironment = yield* HostProcessEnvironment; - const platform = yield* HostProcessPlatform; - const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader; + const hostEnvironment = yield* HostProcess.Environment; + // The readers yield their own services; scans run them against this context. + const readerContext = yield* Effect.context(); const fileCache: ScanCache = new Map(); - const antigravityCache = makeAntigravityUsageCache(); const sourceCache = new Map(); let cacheDirty = false; - /** Cursor account caches by credential source. */ - const cursorCaches = new Map(); - let cursorCacheDirty = false; - /** - * The last failed refresh per credential source, standing for a TTL so a - * client refetching a broken login does not refetch Cursor each time. A - * `null` error means there is no login: no source to report. - */ - const cursorFailures = new Map< - string, - { readonly atMs: number; readonly error: string | null } - >(); - /** The refresh in flight per credential source, which every read joins. */ - const cursorRefreshes = new Map>(); const isWithinDirectory = (filePath: string, dir: string) => { const relative = path.relative(dir, filePath); return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); @@ -245,7 +242,6 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); - const cursorCachePath = path.join(config.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); const writeCacheFile = (filePath: string, contents: string) => writeFileStringAtomically({ filePath, contents }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -338,60 +334,55 @@ export const make = Effect.gen(function* () { ), ); - /** Resolves the transcript directory for each provider. */ + /** + * Every instance of `driver` with its decoded config, or none when it does + * not decode. Disabled accounts still have history. An unconfigured default + * slot runs with default config, just as it does in the provider registry. + */ + const usageInstances = Effect.fn("UsageService.usageInstances")(function* ( + driver: ProviderDriver, + settings: ServerSettingsValue, + ) { + const entries: Array< + readonly [ProviderInstanceId, Pick, boolean] + > = Object.entries(settings.providerInstances) + .filter(([, instance]) => instance.driver === driver.driverKind) + .map(([id, instance]) => [ProviderInstanceId.make(id), instance, true] as const); + if (!Object.hasOwn(settings.providerInstances, driver.driverKind)) { + entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]); + } + const decodeConfig = Schema.decodeUnknownOption(driver.configSchema); + return yield* Effect.forEach( + entries, + Effect.fnUntraced(function* ([instanceId, instance, configured]) { + const instanceConfig: ProviderUsageInstance = { + instanceId, + config: Option.getOrUndefined(decodeConfig(instance.config ?? {})), + environment: yield* mergeProviderInstanceEnvironment( + instance.environment, + hostEnvironment, + ), + configured, + }; + return instanceConfig; + }), + ); + }); + + /** Resolves every transcript directory the usage readers point at. */ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, retentionCutoffMs: number, ) { - const dirs: Array<{ - provider: UsageProviderKind; - dir: string; - volumeId: string; - fileName?: string; - }> = []; + const dirs: Array = []; const seen = new Set(); - for (const driver of ["claudeAgent", "codex", "grok"] as const) { - // Disabled accounts still have history. An unconfigured default slot - // runs with default config, just as it does in the provider registry. - const instances: Array< - Pick & { instanceId: ProviderInstanceId } - > = Object.entries(settings.providerInstances) - .filter(([, instance]) => instance.driver === driver) - .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); - if (!Object.hasOwn(settings.providerInstances, driver)) { - instances.push({ instanceId: ProviderInstanceId.make(driver) }); - } - for (const instance of instances) { - const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); - const provider = driver === "claudeAgent" ? "claude" : driver; - let home: string; - if (driver === "codex") { - const decoded = decodeCodexSettings(instance.config ?? {}); - if (Option.isNone(decoded)) continue; - const codexConfig = decoded.value; - const environmentHome = environment.CODEX_HOME?.trim(); - const layout = yield* resolveCodexHomeLayout( - codexConfig.setupMode !== "managed" && - !codexConfig.homePath.trim() && - !codexConfig.shadowHomePath.trim() && - environmentHome - ? { ...codexConfig, homePath: environmentHome } - : codexConfig, - ); - home = layout.sharedHomePath; - } else if (driver === "claudeAgent") { - const decoded = decodeClaudeSettings(instance.config ?? {}); - if (Option.isNone(decoded)) continue; - const configured = decoded.value.homePath.trim(); - home = configured - ? expandHomePath(configured) - : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude"); - } else { - home = expandHomePath( - environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"), - ); - } - const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); + for (const { driver, reader } of transcriptReaders) { + const { provider, format } = reader; + const directories = yield* Effect.forEach( + yield* usageInstances(driver, settings), + (instance) => reader.directories(instance), + ); + for (const { dir: directory, fileName } of directories.flat()) { const sourceKey = provider + "\0" + directory; const previous = sourceCache.get(sourceKey); // Keep canonical paths and source fingerprints stable after root cleanup, @@ -423,9 +414,10 @@ export const make = Effect.gen(function* () { seen.add(key); dirs.push({ provider, + format, dir, volumeId, - ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}), + ...(fileName === undefined ? {} : { fileName }), }); } } @@ -433,7 +425,7 @@ export const make = Effect.gen(function* () { }); /** - * Loads the persisted scan and Cursor account caches exactly once per process. + * Loads the persisted scan cache exactly once per process. * * `Effect.cached` makes concurrent first readers await the same load rather * than each seeing a "loaded" flag set before the read finished and cold @@ -446,9 +438,6 @@ export const make = Effect.gen(function* () { Effect.flatMap((raw) => decodeScanCacheFile(raw)), Effect.catchCause(() => Effect.succeed(null)), ); - for (const [key, cache] of decodeCursorAccountCaches(yield* readDocument(cursorCachePath))) { - cursorCaches.set(key, cache); - } let document = yield* readDocument(scanCachePath); if (document === null) { document = yield* readDocument(legacyScanCachePath); @@ -456,7 +445,9 @@ export const make = Effect.gen(function* () { cacheDirty = document !== null; } if (document === null) return; - for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + for (const [path, entry] of decodeScanCache(document, transcriptFormats)) { + fileCache.set(path, entry); + } const sources = decodeCachedSources(document); if (Option.isSome(sources)) { for (const [key, source] of Object.entries(sources.value.sources)) @@ -470,7 +461,7 @@ export const make = Effect.gen(function* () { // keeps an older snapshot from landing after a newer one. const persistLock = yield* Semaphore.make(1); - // Each dirty flag is cleared before encoding, so a change while the write is + // The dirty flag is cleared before encoding, so a change while the write is // in flight marks it dirty again. A failed write restores the flag, so the // next persist retries instead of leaving disk stale. A cache we cannot // write is a slower next start, not a failed read. @@ -488,17 +479,6 @@ export const make = Effect.gen(function* () { ), ); } - if (cursorCacheDirty) { - cursorCacheDirty = false; - yield* Effect.sync(() => encodeCursorAccountCaches(cursorCaches)).pipe( - Effect.flatMap((contents) => writeCacheFile(cursorCachePath, contents)), - Effect.catchCause(() => - Effect.sync(() => { - cursorCacheDirty = true; - }), - ), - ); - } }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches")); const pendingPersists = new Set>(); @@ -536,6 +516,7 @@ export const make = Effect.gen(function* () { size: number, mtimeMs: number, provider: UsageProviderKind, + format: TranscriptUsageFormat, ): Effect.Effect<{ readonly records: readonly UsageRecord[]; readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; @@ -566,7 +547,7 @@ export const make = Effect.gen(function* () { : undefined; const parsed = yield* Effect.promise(() => - readTranscriptRecords(filePath, provider, resumeFrom), + readTranscriptRecords(filePath, format, resumeFrom), ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. @@ -611,15 +592,10 @@ export const make = Effect.gen(function* () { } const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* ( - source: { - readonly provider: UsageProviderKind; - readonly dir: string; - readonly volumeId: string; - readonly fileName?: string; - }, + source: TranscriptSource, windowStartMs: number, ) { - const { provider, dir, volumeId, fileName } = source; + const { provider, format, dir, volumeId, fileName } = source; const exists = yield* fileSystem .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); @@ -632,7 +608,7 @@ export const make = Effect.gen(function* () { const read = yield* Effect.forEach( files, (file) => - readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( + readFileRecords(file.path, file.size, file.mtimeMs, provider, format).pipe( Effect.map((result) => ({ path: file.path, ...result })), ), { concurrency: TRANSCRIPT_READ_CONCURRENCY }, @@ -656,352 +632,53 @@ export const make = Effect.gen(function* () { return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir; }); - /** Fetches what one account cache is missing, then persists it if anything changed. */ - const refreshCursorAccount = Effect.fn("UsageService.refreshCursorAccount")(function* ( - credential: CursorCredentialSource, - credentialKey: string, - retentionStartMs: number, - ) { - const nowMs = yield* Clock.currentTimeMillis; - const fetchMissing = (cache: CursorAccountCache | undefined) => { - const range = cursorFetchRange(cache, retentionStartMs, nowMs); - return cursorAccountReader - .read(credential, range.sinceMs, range.untilMs) - .pipe(Effect.map((result) => ({ range, result }))); - }; - let base = cursorCaches.get(credentialKey); - let fetched = yield* fetchMissing(base); - // Another login's history replaces the cached account's, even if reading it fails. - if ( - base !== undefined && - fetched.result.accountKey !== null && - fetched.result.accountKey !== base.accountKey - ) { - cursorCaches.delete(credentialKey); - cursorCacheDirty = true; - base = undefined; - fetched = yield* fetchMissing(base); - } - const { range, result } = fetched; - if (result.missing || result.error !== null || result.accountKey === null) { - cursorFailures.set(credentialKey, { - atMs: nowMs, - error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR, - }); - yield* schedulePersist; - return; - } - const merged = mergeCursorFetch( - base, - result.accountKey, - range, - result.records, - nowMs, - retentionStartMs, - ); - cursorCaches.set(credentialKey, merged.cache); - cursorFailures.delete(credentialKey); - // An unchanged edge is not worth rewriting the file for: after a restart - // the cache just refetches a slightly wider edge. - if (merged.changed) { - cursorCacheDirty = true; - yield* schedulePersist; - } - }); - - /** Joins the refresh in flight, or starts one. */ - const startCursorRefresh = ( - credential: CursorCredentialSource, - credentialKey: string, - retentionStartMs: number, - ) => - // Enrollment and fork are atomic, so an interrupted caller cannot leave a - // registered refresh that nothing will finish. - Effect.uninterruptible( - Effect.gen(function* () { - const current = cursorRefreshes.get(credentialKey); - if (current !== undefined) return current; - const done = Deferred.makeUnsafe(); - cursorRefreshes.set(credentialKey, done); - // Detached: a departing client must not cancel a fetch later reads reuse. - yield* refreshCursorAccount(credential, credentialKey, retentionStartMs).pipe( - Effect.catchCause(() => - Clock.currentTimeMillis.pipe( - Effect.map((atMs) => - cursorFailures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }), - ), - ), - ), - Effect.ensuring( - Effect.suspend(() => { - cursorRefreshes.delete(credentialKey); - return Deferred.succeed(done, undefined); - }), - ), - Effect.forkDetach, - ); - return done; - }), - ); - - /** - * The Cursor account source. A cache inside its TTL, or a refresh that failed - * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh` - * waits for it, and anything else answers from the cache marked `refreshing`. - */ - const cursorAccountSource = Effect.fn("UsageService.cursorAccountSource")(function* ( - credential: CursorCredentialSource, - authPath: string, - windowStartMs: number, - retentionStartMs: number, - awaitRefresh: boolean, - ) { - // No saved login means there is no account source to report, not a setup error. - if ( - typeof credential === "string" && - !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true))) - ) { - return null; - } - const credentialKey = typeof credential === "string" ? credential : "keychain"; - const nowMs = yield* Clock.currentTimeMillis; - const recentFailure = cursorFailures.get(credentialKey); - let refreshing = false; - if ( - (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) && - !isCursorCacheFresh(cursorCaches.get(credentialKey), nowMs) - ) { - const refresh = yield* startCursorRefresh(credential, credentialKey, retentionStartMs); - if (awaitRefresh) yield* Deferred.await(refresh); - else refreshing = true; - } - - const cache = cursorCaches.get(credentialKey); - const failure = refreshing ? undefined : cursorFailures.get(credentialKey); - const failureMessage = failure === undefined ? undefined : failure.error; - if (failureMessage === null) return null; - if (cache === undefined) { - return { - provider: "cursor", - dir: authPath, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(authPath)), - // Never combine a local fallback with another server's account-wide history. - ...(refreshing - ? { files: [], refreshing: true } - : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), - } satisfies ScannedDir; - } - // The same account includes CLI and desktop history from every machine. - // A stable remote fingerprint prevents connected environments counting it twice. - const source = `cursor-account:${cache.accountKey}`; - return { - provider: "cursor", - dir: source, - hostId: "cursor.com", - volumeId: cache.accountKey, - files: [ - { - path: source, - records: cache.records.filter((record) => record.timestampMs >= windowStartMs), - }, - ], - ...(failureMessage === undefined - ? { status: "ok" } - : { status: "partial", message: failureMessage }), - ...(refreshing ? { refreshing: true } : {}), - } satisfies ScannedDir; - }); - const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( windowStartMs: number, settings: ServerSettingsValue, retentionCutoffMs: number, awaitRefresh: boolean, ) { - // The home resolvers ask for `Path` themselves; satisfy them from the - // instance we already hold so the scan stays context-free. const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( - Effect.provideService(Path.Path, path), + Effect.provideContext(readerContext), ); - const home = NodeOS.homedir(); - const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) { - const roots = hostEnvironment[key] - ?.split(",") - .map((value) => value.trim()) - .filter(Boolean); - const canonical = new Set(); - for (const root of roots?.length ? roots : defaults) { - const resolved = path.resolve(expandHomePath(root)); - canonical.add( - yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)), - ); - } - return [...canonical]; - }); - const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim(); - - const openCode = Effect.gen(function* () { - const roots = yield* envRoots("OPENCODE_DATA_DIR", [ - path.join( - dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), - "opencode", - ), - ]); - return yield* Effect.forEach( - roots, - (dir) => - Effect.gen(function* () { - const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs)); - return { - provider: "opencode", - dir, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), - files: result.missing && !result.error ? null : result.files, - status: result.error ? "partial" : "ok", - ...(result.error ? { message: "Some OpenCode history could not be read." } : {}), - } satisfies ScannedDir; - }), - { concurrency: "unbounded" }, - ); - }); - - const antigravity = Effect.gen(function* () { - const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [ - ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => - path.join(home, ".gemini", name), - ), - path.join(home, ".config", "antigravity"), - ]); - for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { - if (instance.driver === "antigravity") { - const directories = yield* resolveAntigravityInstanceDirectories( - config.stateDir, - ProviderInstanceId.make(instanceId), - ).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new UsageReadError({ - reason: "scanFailed", - detail: "Antigravity profile directory could not be resolved.", - cause, - }), + const scans = Effect.forEach( + scanReaders, + ({ driver, reader }) => + usageInstances(driver, settings) + .pipe( + Effect.flatMap((instances) => + reader.scan({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }), ), - ); - antigravityRoots.push(path.join(directories.profile, "antigravity-acp")); - } - } - const antigravityDirs = new Set(); - for (const root of antigravityRoots) { - const resolvedRoot = yield* fileSystem - .realPath(root) - .pipe(Effect.orElseSucceed(() => root)); - const nested = path.join(resolvedRoot, "conversations"); - const dir = (yield* fileSystem - .exists(nested) - .pipe(Effect.catchCause(() => Effect.succeed(false)))) - ? nested - : resolvedRoot; - antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir))); - } - const result = yield* Effect.promise(() => - readAntigravityUsage([...antigravityDirs], windowStartMs, antigravityCache), - ); - const scanned: ScannedDir[] = []; - for (const dir of antigravityDirs) { - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - const failed = result.errors.some( - (error) => error === dir || error.startsWith(`${dir}${path.sep}`), - ); - scanned.push({ - provider: "antigravity", - dir, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), - files: !exists && !failed ? null : result.files.filter((file) => file.root === dir), - status: failed ? "partial" : "ok", - ...(failed ? { message: "Some Antigravity history could not be read." } : {}), - }); - } - return scanned; - }); - - const cursor = Effect.gen(function* () { - const cursorUserHome = - (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; - const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); - const cursorHome = - platform === "darwin" - ? path.join(cursorUserHome, "Library", "Application Support") - : platform === "win32" - ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") - : configHome && path.isAbsolute(configHome) - ? configHome - : path.join(cursorUserHome, ".config"); - const cursorAuthPath = - platform === "darwin" - ? path.join(cursorUserHome, ".cursor", "auth.json") - : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); - const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; - const loginUnavailable = - Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || - Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || - credentialStore === "memory"; - const useKeychain = platform === "darwin" && credentialStore !== "file"; - if (useKeychain && !loginUnavailable && !settings.cursorKeychainUsageEnabled) { - return [ - { - provider: "cursor", - dir: cursorAuthPath, - volumeId: "", - files: null, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - } satisfies ScannedDir, - ]; - } - if (loginUnavailable) { - return [ - { - provider: "cursor", - dir: cursorAuthPath, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), - files: null, - message: "Cursor account history needs a Cursor CLI login on this server.", - } satisfies ScannedDir, - ]; - } - const source = yield* cursorAccountSource( - useKeychain ? { kind: "keychain" } : cursorAuthPath, - cursorAuthPath, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - ); - return source === null ? [] : [source]; - }); + ) + .pipe( + Effect.flatMap((sources) => + Effect.forEach(sources, ({ volumeId, ...source }) => + Effect.map( + volumeId === undefined + ? Effect.promise(() => readDirectoryVolumeId(source.dir)) + : Effect.succeed(volumeId), + (resolved): ScannedDir => ({ + ...source, + provider: reader.provider, + volumeId: resolved, + }), + ), + ), + ), + Effect.provideContext(readerContext), + ), + { concurrency: "unbounded" }, + ); // Independent sources scan together. Transcript directories go one at a // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result // keeps this order, since aggregation keeps the first copy of a duplicate. - const [transcripts, openCodeDirs, antigravityDirs, cursorDirs] = yield* Effect.all( - [ - Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), - openCode, - antigravity, - cursor, - ], + const [transcripts, scanDirs] = yield* Effect.all( + [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], { concurrency: "unbounded" }, ); - const scanned: readonly ScannedDir[] = [ - ...transcripts, - ...openCodeDirs, - ...antigravityDirs, - ...cursorDirs, - ]; + const scanned: readonly ScannedDir[] = [...transcripts, ...scanDirs.flat()]; return scanned; }); @@ -1100,7 +777,7 @@ export const make = Effect.gen(function* () { } return retainedFiles; }); - const sharedSessions = sharedCodexSessions(filesByDir.flat()); + const crossFileSessions = sharedSessions(filesByDir.flat()); for (const [ index, @@ -1118,12 +795,15 @@ export const make = Effect.gen(function* () { continue; } scannedFiles += 1; - const codexEventOccurrences = new Map(); + const eventOccurrences = new Map(); for (const record of file.records) { let usageRecord = record; - if (record.provider === "codex" && sharedSessions.has(record.sessionId)) { - // Match moved rollout copies without collapsing repeated equal events - // within one rollout (timestamps can have only second precision). + if ( + sharedSessionProviders.has(record.provider) && + crossFileSessions.has(record.sessionId) + ) { + // Match moved copies without collapsing repeated equal events + // within one file (timestamps can have only second precision). // Only sessions seen in several files can have a copy to match. const key = encodeUsageRecordKey([ record.provider, @@ -1132,8 +812,8 @@ export const make = Effect.gen(function* () { record.model, record.totals, ]); - const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1; - codexEventOccurrences.set(key, occurrence); + const occurrence = (eventOccurrences.get(key) ?? 0) + 1; + eventOccurrences.set(key, occurrence); usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; } // Only sessions contributing in-window count; the mtime slack can @@ -1180,9 +860,10 @@ export const make = Effect.gen(function* () { }); /** - * In-flight scans by window and usage settings, so concurrent identical requests (the usage + * In-flight scans by window and settings, so concurrent identical requests (the usage * page open on two clients at once) share one scan instead of racing over - * the same corpus twice. + * the same corpus twice. Readers read settings of their own, so a scan is + * shared only under the same settings snapshot. */ const inflightScans = new Map>(); @@ -1194,9 +875,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - settings.usagePriceOverrides, - settings.usageModelAliases, - settings.cursorKeychainUsageEnabled, + settings, // A waiting read must never share a scan that answers with `refreshing`. input.awaitRefresh === true, ]); diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts deleted file mode 100644 index 4897f4604a9e..000000000000 --- a/apps/server/src/usage/cursorUsageReader.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { CursorKeychainTimeoutError } from "@t3tools/provider-cursor/server"; -import { readCursorAccountUsage } from "./cursorUsageReader.ts"; - -describe("readCursorAccountUsage", () => { - it("asks for Keychain approval when the prompt goes unanswered", async () => { - const result = await readCursorAccountUsage( - { kind: "keychain" }, - 0, - 1, - () => Promise.reject(new Error("no network expected")), - () => Promise.reject(new CursorKeychainTimeoutError()), - ); - assert.deepStrictEqual(result, { - accountKey: null, - records: [], - missing: false, - error: "Allow Keychain access on the Mac running T3 Code, then refresh.", - }); - }); - - it("reads the pages behind the first together and keeps them in page order", async () => { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - let inFlight = 0; - let mostInFlight = 0; - const result = await readCursorAccountUsage( - { kind: "keychain" }, - 0, - 1781000000000, - async (_url, init) => { - const page: number = JSON.parse(String(init.body)).page; - inFlight++; - mostInFlight = Math.max(mostInFlight, inFlight); - // Later pages answer first, so record order cannot come from arrival order. - for (let turn = page; turn < 9; turn++) await Promise.resolve(); - inFlight--; - return Response.json({ - totalUsageEventsCount: 8001, - usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ - timestamp: String(1780000000000 + (page - 1) * 1000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 1 }, - })), - }); - }, - async () => accessToken, - ); - assert.isNull(result.error); - assert.strictEqual(mostInFlight, 6); - assert.deepStrictEqual( - result.records.map((record) => record.timestampMs), - Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), - ); - }); - - it("ends like a page-by-page read when a page fails, leaving no request open", async () => { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const fullPage = { - totalUsageEventsCount: 8001, - usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })), - }; - // Page 3 fails while the pages after it are still waiting on Cursor. - for (const [secondPage, error] of [ - [() => Response.json(fullPage), "Cursor account usage could not be read."], - [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."], - ] as const) { - let open = 0; - const result = await readCursorAccountUsage( - { kind: "keychain" }, - 0, - 1781000000000, - (_url, init) => { - const page: number = JSON.parse(String(init.body)).page; - if (page === 1) return Promise.resolve(Response.json(fullPage)); - if (page === 2) return Promise.resolve(secondPage()); - if (page === 3) return Promise.reject(new Error("connection reset")); - open++; - return new Promise((_resolve, reject) => { - init.signal?.addEventListener("abort", () => { - open--; - reject(init.signal?.reason); - }); - }); - }, - async () => accessToken, - ); - assert.strictEqual(result.error, error); - assert.deepStrictEqual(result.records, []); - assert.strictEqual(open, 0); - } - }); -}); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts deleted file mode 100644 index 95b87f1984bb..000000000000 --- a/apps/server/src/usage/cursorUsageReader.ts +++ /dev/null @@ -1,316 +0,0 @@ -// Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFSP from "node:fs/promises"; -import * as NodeCrypto from "node:crypto"; -import * as NodeTimersPromises from "node:timers/promises"; - -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import type { UsageRecord } from "./usageTranscripts.ts"; -import { - CursorKeychainTimeoutError, - readMacCursorAccessToken, -} from "@t3tools/provider-cursor/server"; - -function object(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function tokens(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; -} - -/** - * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`, - * `claude-fable-5-1-thinking-high`) to the base model's rate-table key. - * Grok resolves through xAI's first-party entry, which has no bare alias. - */ -export function cursorRateModel(model: string): string { - const base = model - .replace(/^cursor-/, "") - .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, ""); - return base.startsWith("grok-") ? `xai/${base}` : base; -} - -export interface CursorAccountUsageReadResult { - readonly accountKey: string | null; - readonly records: readonly UsageRecord[]; - readonly missing: boolean; - readonly error: string | null; -} - -const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (value !== null && typeof value === "object") { - return `{${Object.entries(value) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) - .join(",")}}`; - } - return JSON.stringify(value) ?? "null"; -} - -/** Find the longest exact suffix/prefix overlap in linear time. */ -function boundaryOverlap(previous: readonly string[], current: readonly string[]): number { - const sequence = [...current, "", ...previous]; - const lengths = Array.from({ length: sequence.length }, () => 0); - for (let index = 1; index < sequence.length; index++) { - let length = lengths[index - 1]!; - while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!; - if (sequence[index] === sequence[length]) length++; - lengths[index] = length; - } - return lengths.at(-1) ?? 0; -} - -/** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ -export async function readCursorAccountUsage( - credentialSource: string | { readonly kind: "keychain" }, - sinceMs: number, - endDate: number, - request: (url: string, init: RequestInit) => Promise = globalThis.fetch, - keychainToken: () => Promise = readMacCursorAccessToken, -): Promise { - let accessToken: unknown; - try { - accessToken = - typeof credentialSource === "string" - ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken - : await keychainToken(); - } catch (cause) { - const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT"; - return { - accountKey: null, - records: [], - missing, - error: missing - ? null - : typeof credentialSource === "string" - ? "Cursor credentials could not be read." - : cause instanceof CursorKeychainTimeoutError - ? "Allow Keychain access on the Mac running T3 Code, then refresh." - : "Cursor Keychain credentials could not be read.", - }; - } - if (typeof accessToken !== "string" || !accessToken) { - return { - accountKey: null, - records: [], - missing: true, - error: - typeof credentialSource === "string" - ? null - : "Cursor account history needs a macOS Keychain CLI login on this server.", - }; - } - let accountKey: string | null = null; - const cancel = new AbortController(); - try { - const payload = accessToken.split(".")[1]; - const subject = object( - JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")), - ).sub; - if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication"); - const userId = subject.split("|").at(-1); - if (!userId) throw new Error("Invalid authentication"); - accountKey = accountHash(subject); - if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) - throw new Error("Invalid date window"); - const deadline = AbortSignal.any([cancel.signal, AbortSignal.timeout(60_000)]); - const records: UsageRecord[] = []; - const occurrences = new Map(); - const pages: unknown[][] = []; - let completed = false; - const pageSize = 1000; - let total: number | undefined; - const readPage = async (page: number) => { - // A count can include overlapping page boundaries. Allow room to - // reconcile them without imposing a fixed account-size limit. - if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { - throw new Error("Account usage page limit exceeded"); - } - const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { - method: "POST", - redirect: "error", - signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]), - headers: { - "Content-Type": "application/json", - Origin: "https://cursor.com", - Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, - }, - body: JSON.stringify({ - page, - pageSize, - startDate: String(sinceMs), - endDate: String(endDate), - }), - }); - if (response.status === 401 || response.status === 403) { - return { - accountKey, - records: [], - missing: false, - error: "Sign in to Cursor again to read account usage.", - }; - } - if (!response.ok) throw new Error("Account usage request failed"); - const parsed: unknown = await response.json(); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Invalid account usage page"); - } - const body = object(parsed); - const keys = Object.keys(body); - if ("error" in body || "message" in body || "code" in body) - throw new Error("Account usage error response"); - const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; - const events = - keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") - ? [] - : body.usageEventsDisplay; - if ( - (count !== undefined && - (typeof count !== "number" || - !Number.isSafeInteger(count) || - count < 0 || - (total !== undefined && count !== total))) || - !Array.isArray(events) || - events.length > pageSize || - (count === undefined && !Array.isArray(body.usageEventsDisplay)) - ) { - throw new Error("Inconsistent account usage page"); - } - if (typeof count === "number") total = count; - return events; - }; - // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages - // the first one's count implies are requested up to six ahead and consumed in page order. - const ahead: ReturnType[] = []; - for (let page = 1; ; page++) { - while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) { - const pending = readPage(page + ahead.length); - // Handled when its turn comes, or cancelled if the read ends first. - pending.catch(() => undefined); - ahead.push(pending); - } - const events = await (ahead.shift() ?? readPage(page)); - // A rejected login comes back as the finished result instead of a page. - if (!Array.isArray(events)) return events; - pages.push(events); - if (events.length < pageSize) { - completed = true; - break; - } - } - if (!completed) throw new Error("Account usage page limit exceeded"); - const rawCount = pages.reduce((sum, page) => sum + page.length, 0); - if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages"); - let removalsRemaining = total === undefined ? 0 : rawCount - total; - let previousKeys: string[] = []; - for (const events of pages) { - const eventKeys = - removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : []; - const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys)); - removalsRemaining -= removalCount; - previousKeys = eventKeys; - for (const raw of events.slice(removalCount)) { - const event = object(raw); - const usage = object(event.tokenUsage); - if (event.tokenUsage === undefined || event.tokenUsage === null) continue; - for (const key of [ - "inputTokens", - "outputTokens", - "cacheReadTokens", - "cacheWriteTokens", - "totalCents", - ]) { - const value = usage[key]; - if ( - value !== undefined && - (typeof value !== "number" || !Number.isFinite(value) || value < 0) - ) { - throw new Error("Invalid account usage totals"); - } - } - const timestampMs = - typeof event.timestamp === "string" && event.timestamp.trim() !== "" - ? Number(event.timestamp) - : event.timestamp; - if ( - typeof timestampMs !== "number" || - !Number.isFinite(timestampMs) || - typeof event.model !== "string" || - !event.model - ) - throw new Error("Invalid account usage event"); - if (timestampMs < sinceMs || timestampMs > endDate) continue; - const totals = { - uncachedInputTokens: tokens(usage.inputTokens), - cachedInputTokens: tokens(usage.cacheReadTokens), - cacheCreationTokens: tokens(usage.cacheWriteTokens), - outputTokens: tokens(usage.outputTokens), - reasoningTokens: 0, - }; - const reportedCostUsd = - typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; - const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; - // No event ID is provided. Preserve identical billed rows with an occurrence index. - const key = accountHash( - JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]), - ); - const occurrence = occurrences.get(key) ?? 0; - occurrences.set(key, occurrence + 1); - records.push({ - provider: "cursor", - timestampMs, - model: event.model, - rateModel: cursorRateModel(event.model), - sessionId, - totals, - reportedCostUsd, - speed: "standard", - dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, - }); - } - await NodeTimersPromises.setImmediate(); - } - if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries"); - return { accountKey, records, missing: false, error: null }; - } catch { - return { - accountKey, - records: [], - missing: false, - error: "Cursor account usage could not be read.", - }; - } finally { - // An early exit leaves the pages requested ahead of it in flight. - cancel.abort(); - } -} - -/** Reads one range of Cursor account usage. A service so tests can stand in for Cursor's API. */ -export class CursorAccountReader extends Context.Service< - CursorAccountReader, - { - readonly read: ( - credentialSource: string | { readonly kind: "keychain" }, - sinceMs: number, - untilMs: number, - ) => Effect.Effect; - } ->()("t3/usage/cursorUsageReader/CursorAccountReader") {} - -/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */ -export const layer = Layer.succeed( - CursorAccountReader, - CursorAccountReader.of({ - read: (credentialSource, sinceMs, untilMs) => - Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)), - }), -); diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts deleted file mode 100644 index d4414ac7b9a6..000000000000 --- a/apps/server/src/usage/opencodeUsageReader.ts +++ /dev/null @@ -1,188 +0,0 @@ -// node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history. -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFSP from "node:fs/promises"; -import * as NodePath from "node:path"; -import * as NodeSqlite from "node:sqlite"; -import * as NodeTimersPromises from "node:timers/promises"; - -import { totalTokens, type UsageRecord } from "./usageTranscripts.ts"; - -function object(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function tokens(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; -} - -function text(value: unknown): string { - return typeof value === "string" ? value : ""; -} - -/** OpenCode stores uncached input and reasoning separately from input/output. */ -function parseOpenCodeMessage( - source: string, - fallback: { - readonly id?: string; - readonly sessionId?: string; - readonly timestampMs?: number; - } = {}, -): UsageRecord | null { - let parsed: unknown; - try { - parsed = JSON.parse(source); - } catch { - return null; - } - const message = object(parsed); - if (message.role !== undefined && message.role !== "assistant") return null; - const usage = object(message.tokens); - const cache = object(usage.cache); - const modelReference = object(message.model); - const model = text(modelReference.id) || text(modelReference.modelID) || text(message.modelID); - const timestampMs = object(message.time).created ?? fallback.timestampMs; - if (!model || typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return null; - const reasoningTokens = tokens(usage.reasoning); - const totals = { - uncachedInputTokens: tokens(usage.input), - cachedInputTokens: tokens(cache.read), - cacheCreationTokens: tokens(cache.write), - outputTokens: tokens(usage.output) + reasoningTokens, - reasoningTokens, - }; - if (totalTokens(totals) === 0) return null; - const id = fallback.id || text(message.id); - const cost = message.cost; - return { - provider: "opencode", - timestampMs, - model, - sessionId: fallback.sessionId || text(message.sessionID), - totals, - // OpenCode writes zero for models without a known rate, including paid - // subscription models. Let the shared price table estimate those records. - reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null, - speed: "standard", - dedupeKey: id ? `opencode:${id}` : null, - }; -} - -export interface OpenCodeUsageReadResult { - readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]; - readonly missing: boolean; - readonly error: boolean; -} - -/** Reads current SQLite and pre-migration JSON stores without modifying either. */ -export async function readOpenCodeUsage( - root: string, - sinceMs: number, -): Promise { - const files: { path: string; records: UsageRecord[] }[] = []; - const seen = new Set(); - let found = false; - let error = false; - const append = (records: UsageRecord[], record: UsageRecord | null) => { - if (record === null || record.timestampMs < sinceMs) return; - if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) return; - seen.add(record.dedupeKey); - } - records.push(record); - }; - - let databases: string[] = []; - try { - databases = (await NodeFSP.readdir(root, { withFileTypes: true })) - .filter((entry) => entry.isFile() && /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(entry.name)) - .map((entry) => entry.name) - .sort((a, b) => (a === "opencode.db" ? -1 : b === "opencode.db" ? 1 : a.localeCompare(b))); - } catch (cause) { - if (object(cause).code !== "ENOENT") error = true; - } - for (const name of databases) { - found = true; - const file = { path: NodePath.join(root, name), records: [] as UsageRecord[] }; - files.push(file); - let database: NodeSqlite.DatabaseSync | undefined; - try { - database = new NodeSqlite.DatabaseSync(NodePath.join(root, name), { readOnly: true }); - // A busy live provider should fail this source promptly rather than - // stalling the server while SQLite waits for its writer. - database.exec("PRAGMA busy_timeout = 100"); - const tables = new Set( - database - .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") - .all() - .map((row) => row.name), - ); - if (!tables.has("message") && !tables.has("session_message")) error = true; - for (const table of ["message", "session_message"] as const) { - if (!tables.has(table)) continue; - const columns = new Set( - database - .prepare(`PRAGMA table_info(${table})`) - .all() - .map((row) => row.name), - ); - const timestamp = columns.has("time_created") ? "time_created" : "NULL"; - const predicates = table === "session_message" ? ["type = 'assistant'"] : []; - if (timestamp !== "NULL") predicates.push("time_created >= ?"); - const where = predicates.length > 0 ? ` WHERE ${predicates.join(" AND ")}` : ""; - const statement = database.prepare( - `SELECT id, session_id, data, ${timestamp} AS created FROM ${table}${where}`, - ); - let count = 0; - for (const row of statement.iterate(...(timestamp === "NULL" ? [] : [sinceMs]))) { - append( - file.records, - parseOpenCodeMessage(text(row.data), { - id: text(row.id), - sessionId: text(row.session_id), - ...(typeof row.created === "number" ? { timestampMs: row.created } : {}), - }), - ); - if (++count % 256 === 0) await NodeTimersPromises.setImmediate(); - } - } - } catch { - error = true; - } finally { - database?.close(); - } - } - - // Do not follow symlinks, including cycles. Database records win over their - // old JSON copies when OpenCode has migrated a store in place. - const directories = [NodePath.join(root, "storage", "message")]; - while (directories.length > 0) { - const directory = directories.pop()!; - try { - for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) { - const path = NodePath.join(directory, entry.name); - if (entry.isDirectory()) { - directories.push(path); - } else if (entry.isFile() && entry.name.endsWith(".json")) { - found = true; - const id = entry.name.slice(0, -5); - if (seen.has(`opencode:${id}`)) continue; - const file = { path, records: [] as UsageRecord[] }; - files.push(file); - try { - append( - file.records, - parseOpenCodeMessage(await NodeFSP.readFile(path, "utf8"), { id }), - ); - } catch (cause) { - if (object(cause).code !== "ENOENT") error = true; - } - } - } - } catch (cause) { - if (object(cause).code !== "ENOENT") error = true; - } - } - return { files, missing: !found && !error, error }; -} diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index a27a9b101cc3..037928b2e7f2 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import type { UsageRecord } from "@t3tools/provider-core/server/usage"; const rates: RateTable = new Map([ [ diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index f2923871a323..50ccaad34f3c 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -20,7 +20,7 @@ import type { UsageTokenTotals, } from "@t3tools/contracts"; -import { EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; /** diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 0a324dd734b4..b13a06a1c2d0 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { cursorRateModel } from "./cursorUsageReader.ts"; -import type { UsageSpeed } from "./usageTranscripts.ts"; +import { cursorRateModel } from "@t3tools/provider-cursor/server/accountUsage"; +import type { UsageSpeed } from "@t3tools/provider-core/server/usage"; import { cacheSavingsUsd, createOverrideRateTable, diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 0ec7ef8d622f..dda4e8257421 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -14,7 +14,7 @@ import type { UsageTokenTotals, } from "@t3tools/contracts"; -import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; +import type { UsageRecord, UsageSpeed } from "@t3tools/provider-core/server/usage"; /** Token rates for one billing speed. All values are USD per token. */ export interface TokenRates { diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index b59768cf892b..7d5637341b61 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -9,7 +9,9 @@ import { type CachedFile, type ScanCache, } from "./usageScanCache.ts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import type { UsageRecord } from "@t3tools/provider-core/server/usage"; + +import { TEST_FORMAT_MAP } from "./usageTestFormats.ts"; function record(overrides: Partial = {}): UsageRecord { return { @@ -36,7 +38,7 @@ function position(overrides: Partial = {}): CachedFile[" resumeOffset: 120, guardLength: 64, guardHash: 0xdeadbeef, - codexState: null, + state: null, ...overrides, }; } @@ -85,7 +87,7 @@ describe("scan cache round trip", () => { ], tailRecords: [], position: position({ - codexState: { + state: { model: "gpt-6-astra", speed: "ultrafast", sessionId: "session-c", @@ -97,7 +99,10 @@ describe("scan cache round trip", () => { }), }); - const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + const restored = decodeScanCache( + JSON.parse(JSON.stringify(encodeScanCache(original))), + TEST_FORMAT_MAP, + ); expect(restored.size).toBe(4); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); @@ -117,7 +122,9 @@ describe("scan cache round trip", () => { }, }; - expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + expect( + decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), + ).toBe(false); }); it("drops an entry whose guard length is outside the supported range", () => { @@ -129,7 +136,9 @@ describe("scan cache round trip", () => { files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, }; - expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + expect( + decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), + ).toBe(false); }); it("drops an entry whose speed is not a known index", () => { @@ -140,14 +149,16 @@ describe("scan cache round trip", () => { files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } }, }; - expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + expect( + decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), + ).toBe(false); }); it("rejects a document from before records carried a speed", () => { const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); const previous = { ...encoded, version: 3 }; - expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); + expect(decodeScanCache(JSON.parse(JSON.stringify(previous)), TEST_FORMAT_MAP).size).toBe(0); }); it("rewrites only changed entries and still restores the whole cache", () => { @@ -157,7 +168,7 @@ describe("scan cache round trip", () => { ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], ]); const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; - expect(decodeScanCache(JSON.parse(write(cache, { sources })))).toEqual(cache); + expect(decodeScanCache(JSON.parse(write(cache, { sources })), TEST_FORMAT_MAP)).toEqual(cache); // The replacement adds intern entries; /a's memoised indexes must hold. cache.set("/b.jsonl", { @@ -166,7 +177,7 @@ describe("scan cache round trip", () => { records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], }); const document = JSON.parse(write(cache, { sources })); - expect(decodeScanCache(document)).toEqual(cache); + expect(decodeScanCache(document, TEST_FORMAT_MAP)).toEqual(cache); expect(document.sources).toEqual(sources); }); @@ -181,9 +192,11 @@ describe("scan cache round trip", () => { it("treats a corrupt or foreign document as an empty cache", () => { // A bad cache should cost one cold scan, never a broken page. - expect(decodeScanCache(null).size).toBe(0); - expect(decodeScanCache("nonsense").size).toBe(0); - expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0); + expect(decodeScanCache(null, TEST_FORMAT_MAP).size).toBe(0); + expect(decodeScanCache("nonsense", TEST_FORMAT_MAP).size).toBe(0); + expect( + decodeScanCache({ version: 999, models: [], sessions: [], files: {} }, TEST_FORMAT_MAP).size, + ).toBe(0); }); it("skips malformed file entries but keeps good ones", () => { @@ -193,7 +206,7 @@ describe("scan cache round trip", () => { files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } }, }; - const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk))); + const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk)), TEST_FORMAT_MAP); expect([...restored.keys()]).toEqual(["/good.jsonl"]); }); @@ -203,7 +216,7 @@ describe("scan cache round trip", () => { const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); const poisoned = { ...encoded, models: [1] }; - expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0); + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).size).toBe(0); }); it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { @@ -223,7 +236,7 @@ describe("scan cache round trip", () => { }, }; - const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); + const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP); expect(restored.has("/a.jsonl")).toBe(false); }); }); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 04e7ab4ce982..4673a4d8d072 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -15,9 +15,14 @@ * @module usageScanCache */ import type { UsageProviderKind } from "@t3tools/contracts"; +import type { + TranscriptUsageFormat, + UsageRecord, + UsageSpeed, +} from "@t3tools/provider-core/server/usage"; +import * as Schema from "effect/Schema"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. @@ -25,7 +30,7 @@ import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts // re-parses only its appended bytes instead of starting over. // v4: records carry Claude fast mode, which v3 rows never captured. // v5: Codex records carry their service tier. v4 rows store speed the same -// way, so v4 entries still load; see `decodeScanCache` for v4 Codex entries. +// way, so v4 entries still load; see `decodeScanCache` for v4 stateful entries. const USAGE_SCAN_CACHE_VERSION = 5 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; @@ -41,10 +46,6 @@ export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json"; /** Serialised as the index into this list. */ const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"]; -function isSpeed(value: unknown): value is UsageSpeed { - return SPEEDS.some((speed) => speed === value); -} - export interface CachedFile { readonly size: number; readonly mtimeMs: number; @@ -92,8 +93,8 @@ interface SerializedFile { readonly o: number; readonly gl: number; readonly gh: number; - /** Codex reducer state at `o`; `null` for stateless providers. */ - readonly cs: CodexScanState | null; + /** The format's encoded reducer state at `o`; `null` for stateless formats. */ + readonly cs: unknown; } interface SerializedCache { @@ -147,7 +148,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile o: entry.position.resumeOffset, gl: entry.position.guardLength, gh: entry.position.guardHash, - cs: entry.position.codexState, + cs: entry.position.state, }; } @@ -210,10 +211,15 @@ function isRecordArray(value: unknown): value is readonly unknown[] { * Rebuilds the cache from a parsed document. * * Anything malformed yields an empty cache rather than an error: a corrupt - * cache should cost one cold scan, never a broken page. + * cache should cost one cold scan, never a broken page. Entries of a provider + * without a transcript format in `formats` are dropped. */ -export function decodeScanCache(document: unknown): ScanCache { +export function decodeScanCache( + document: unknown, + formats: ReadonlyMap>, +): ScanCache { const cache: ScanCache = new Map(); + const isValidState = makeStateValidators(formats); if (typeof document !== "object" || document === null) return cache; const root = document as Partial; @@ -300,7 +306,9 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; + if (entry.p === undefined) continue; + const format = formats.get(entry.p); + if (format === undefined) continue; if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; // Position fields feed byte offsets and a Buffer allocation in the reader, // so anything outside their real ranges must reject the entry: a bogus @@ -320,27 +328,34 @@ export function decodeScanCache(document: unknown): ScanCache { ) { continue; } - // v4 Codex records predate service tiers, so they all priced as standard. - // Keep them, because the rollout may be gone, but make a live rollout - // re-parse whole: no file has size -1, and a zero position cannot resume. - const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION; - const codexState = legacyCodex ? null : decodeCodexState(entry.cs); - if (codexState === undefined) continue; + // v4 records of stateful formats (Codex) predate service tiers, so they + // all priced as standard. Keep them, because the rollout may be gone, but + // make a live rollout re-parse whole: no file has size -1, and a zero + // position cannot resume. + const legacy = format.state !== undefined && version < USAGE_SCAN_CACHE_VERSION; + // 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; - const provider: UsageProviderKind = entry.p; + const provider = entry.p; const records = decodeRecords(entry.r, provider); const tailRecords = decodeRecords(entry.t, provider); if (records === null || tailRecords === null) continue; cache.set(path, { - size: legacyCodex ? -1 : entry.s, + size: legacy ? -1 : entry.s, mtimeMs: entry.m, provider, records, tailRecords, - position: legacyCodex - ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null } - : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState }, + position: legacy + ? { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null } + : { + resumeOffset: entry.o, + guardLength: entry.gl, + guardHash: entry.gh, + state: entry.cs, + }, }); } @@ -348,35 +363,21 @@ export function decodeScanCache(document: unknown): ScanCache { } /** - * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt - * value, which disqualifies the entry: resuming with a bad state would attach - * appended usage to the wrong model or replay fork-copied history. + * Per provider, whether a persisted state is valid: `null`, or one the + * format's schema accepts. */ -function decodeCodexState(value: unknown): CodexScanState | null | undefined { - if (value === null) return null; - if (typeof value !== "object") return undefined; - const state = value as Partial; - if ( - typeof state.model !== "string" || - !isSpeed(state.speed) || - typeof state.sessionId !== "string" || - (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || - typeof state.sawSessionMeta !== "boolean" || - typeof state.suppressingForkCopies !== "boolean" || - typeof state.forkCopyAnchorMs !== "number" || - !Number.isFinite(state.forkCopyAnchorMs) - ) { - return undefined; - } - return { - model: state.model, - speed: state.speed, - sessionId: state.sessionId, - lastUsageSignature: state.lastUsageSignature ?? null, - sawSessionMeta: state.sawSessionMeta, - suppressingForkCopies: state.suppressingForkCopies, - forkCopyAnchorMs: state.forkCopyAnchorMs, - }; +function makeStateValidators( + formats: ReadonlyMap>, +): ReadonlyMap boolean> { + return new Map( + [...formats].map(([provider, format]) => { + const isState = format.state === undefined ? undefined : Schema.is(format.state.schema); + return [ + provider, + (value: unknown) => value === null || (isState !== undefined && isState(value)), + ] as const; + }), + ); } /** Keeps saved usage after transcript cleanup, until the reporting retention expires. */ diff --git a/apps/server/src/usage/usageTestFormats.ts b/apps/server/src/usage/usageTestFormats.ts new file mode 100644 index 000000000000..be9a84a85b58 --- /dev/null +++ b/apps/server/src/usage/usageTestFormats.ts @@ -0,0 +1,21 @@ +/** + * The built-in transcript formats by provider, for engine and cache tests. + * + * @module usageTestFormats + */ +import type { UsageProviderKind } from "@t3tools/contracts"; +import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; +import { grokUsageFormat } from "@t3tools/provider-grok/server/usage"; + +import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts"; +import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts"; + +export const TEST_FORMATS: Record<"claude" | "codex" | "grok", TranscriptUsageFormat> = { + claude: claudeUsageFormat, + codex: codexUsageFormat, + grok: grokUsageFormat, +}; + +export const TEST_FORMAT_MAP = new Map>( + Object.entries(TEST_FORMATS) as Array<[UsageProviderKind, TranscriptUsageFormat]>, +); diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index e6f19d50718a..e2c4ee1827fe 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -4,40 +4,14 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import * as NodeSqlite from "node:sqlite"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; +import { TEST_FORMATS } from "./usageTestFormats.ts"; import { readTranscriptRecords } from "./usageTranscriptReader.ts"; -import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; -import { readCursorAccountUsage } from "./cursorUsageReader.ts"; -import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; let dir: string; -function protoNumber(field: number, value: number): number[] { - const varint = (number: number) => { - const bytes: number[] = []; - do { - const byte = number % 128; - number = Math.floor(number / 128); - bytes.push(byte + (number > 0 ? 128 : 0)); - } while (number > 0); - return bytes; - }; - return [...varint(field * 8), ...varint(value)]; -} - -function protoBytes(field: number, bytes: readonly number[]): number[] { - const encoded = protoNumber(field, bytes.length); - encoded[0] = encoded[0]! + 2; - return [...encoded, ...bytes]; -} - -function protoText(field: number, value: string): number[] { - return protoBytes(field, [...Buffer.from(value)]); -} - beforeEach(async () => { dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); }); @@ -99,20 +73,20 @@ describe("readTranscriptRecords resume", () => { it("parses only appended lines when resuming a grown file", async () => { const path = NodePath.join(dir, "claude.jsonl"); await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); - const first = await readTranscriptRecords(path, "claude"); + const first = await readTranscriptRecords(path, TEST_FORMATS.claude); assert.isNotNull(first); assert.strictEqual(first.records.length, 2); assert.isFalse(first.resumed); await NodeFSP.appendFile(path, claudeLine(3, 11)); - const second = await readTranscriptRecords(path, "claude", first.position); + const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); assert.isNotNull(second); assert.isTrue(second.resumed); assert.strictEqual(second.records.length, 1); assert.strictEqual(second.records[0]?.totals.outputTokens, 11); // The stitched result matches a from-scratch parse of the whole file. - const full = await readTranscriptRecords(path, "claude"); + const full = await readTranscriptRecords(path, TEST_FORMATS.claude); assert.isNotNull(full); assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); }); @@ -123,7 +97,7 @@ describe("readTranscriptRecords resume", () => { path, codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"), ); - const first = await readTranscriptRecords(path, "codex"); + const first = await readTranscriptRecords(path, TEST_FORMATS.codex); assert.isNotNull(first); assert.strictEqual(first.records.length, 0); @@ -131,7 +105,7 @@ describe("readTranscriptRecords resume", () => { // session_meta of its own; model, tier, and session must come from the // state captured before the boundary. await NodeFSP.appendFile(path, codexUsageLine(9, 5)); - const second = await readTranscriptRecords(path, "codex", first.position); + const second = await readTranscriptRecords(path, TEST_FORMATS.codex, first.position); assert.isNotNull(second); assert.isTrue(second.resumed); assert.strictEqual(second.records.length, 1); @@ -146,14 +120,14 @@ describe("readTranscriptRecords resume", () => { path, codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5), ); - const first = await readTranscriptRecords(path, "codex"); + const first = await readTranscriptRecords(path, TEST_FORMATS.codex); assert.isNotNull(first); assert.strictEqual(first.records.length, 1); // Codex re-emits an unchanged token_count on stream boundaries; the copy // lands after the resume point and must still be dropped. await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); - const second = await readTranscriptRecords(path, "codex", first.position); + const second = await readTranscriptRecords(path, TEST_FORMATS.codex, first.position); assert.isNotNull(second); assert.isTrue(second.resumed); assert.deepStrictEqual( @@ -166,7 +140,7 @@ describe("readTranscriptRecords resume", () => { const path = NodePath.join(dir, "claude.jsonl"); const unterminated = claudeLine(2, 7).trimEnd(); await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated); - const first = await readTranscriptRecords(path, "claude"); + const first = await readTranscriptRecords(path, TEST_FORMATS.claude); assert.isNotNull(first); assert.strictEqual(first.records.length, 1); assert.strictEqual(first.tailRecords.length, 1); @@ -175,7 +149,7 @@ describe("readTranscriptRecords resume", () => { // Completing the line and appending another re-reads from the resume // point, so the once-tail record arrives exactly once as a line record. await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`); - const second = await readTranscriptRecords(path, "claude", first.position); + const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); assert.isNotNull(second); assert.isTrue(second.resumed); assert.deepStrictEqual( @@ -188,12 +162,12 @@ describe("readTranscriptRecords resume", () => { 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)); - const first = await readTranscriptRecords(path, "claude"); + const first = await readTranscriptRecords(path, TEST_FORMATS.claude); assert.isNotNull(first); // Same path, larger size, different content: a replaced file, not growth. await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17)); - const second = await readTranscriptRecords(path, "claude", first.position); + const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); assert.isNotNull(second); assert.isFalse(second.resumed); assert.deepStrictEqual( @@ -205,11 +179,11 @@ describe("readTranscriptRecords resume", () => { it("re-parses from the start when the file shrank below the resume point", async () => { const path = NodePath.join(dir, "claude.jsonl"); await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); - const first = await readTranscriptRecords(path, "claude"); + const first = await readTranscriptRecords(path, TEST_FORMATS.claude); assert.isNotNull(first); await NodeFSP.writeFile(path, claudeLine(3, 11)); - const second = await readTranscriptRecords(path, "claude", first.position); + const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); assert.isNotNull(second); assert.isFalse(second.resumed); assert.deepStrictEqual( @@ -236,7 +210,7 @@ describe("readTranscriptRecords resume", () => { })}\n`; await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7)); - const parsed = await readTranscriptRecords(path, "claude"); + const parsed = await readTranscriptRecords(path, TEST_FORMATS.claude); assert.isNotNull(parsed); assert.deepStrictEqual( parsed.records.map((record) => record.totals.outputTokens), @@ -245,632 +219,8 @@ describe("readTranscriptRecords resume", () => { }); it("returns null for an unreadable file", async () => { - assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); - }); -}); - -describe("SQLite usage readers", () => { - it("reads Cursor account history with the default macOS Keychain login", async () => { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - let keychainReads = 0; - const result = await readCursorAccountUsage( - { kind: "keychain" }, - 0, - 1781000000000, - async (_url, init) => { - assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A"); - return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] }); - }, - async () => { - keychainReads++; - return accessToken; - }, - ); - assert.strictEqual(keychainReads, 1); - assert.isNull(result.error); - assert.isFalse(result.missing); - assert.isNotNull(result.accountKey); - }); - - it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - const pages: number[] = []; - const signals: AbortSignal[] = []; - const request = async (url: string, init: RequestInit) => { - assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); - assert.strictEqual(init?.redirect, "error"); - const headers = new Headers(init?.headers); - assert.strictEqual(headers.get("origin"), "https://cursor.com"); - assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); - const body = JSON.parse(String(init?.body)); - pages.push(body.page); - if (init.signal) signals.push(init.signal); - return Response.json({ - totalUsageEventsCount: 1001, - usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ - timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), - model: "claude-sonnet-4-5", - conversationId: `conversation-${body.page}`, - isHeadless: body.page === 2, - chargedCents: 0, - tokenUsage: { - inputTokens: 10, - outputTokens: 5, - cacheReadTokens: 30, - cacheWriteTokens: 2, - totalCents: 25, - }, - })), - }); - }; - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); - assert.isNull(result.error); - assert.deepStrictEqual(pages, [1, 2]); - assert.lengthOf(signals, 2); - assert.notStrictEqual(signals[0], signals[1]); - assert.strictEqual(result.records.length, 1001); - assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); - assert.deepStrictEqual(result.records[0]?.totals, { - uncachedInputTokens: 10, - cachedInputTokens: 30, - cacheCreationTokens: 2, - outputTokens: 5, - reasoningTokens: 0, - }); - assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); - assert.isFalse(result.accountKey?.includes("demo") ?? true); - }); - - it("reads Cursor account history beyond 100 pages", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); - let requests = 0; - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { - requests += 1; - return Response.json({ - totalUsageEventsCount: 100_001, - usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], - }); - }); - assert.isNull(result.error); - assert.strictEqual(requests, 101); - assert.deepStrictEqual(result.records, []); - }); - - it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - for (const body of [ - {}, - { totalUsageEventsCount: 0 }, - { totalUsageEventsCount: 0, usageEventsDisplay: [] }, - ]) { - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => - Response.json(body), - ); - assert.isNull(result.error); - assert.deepStrictEqual(result.records, []); - assert.isFalse(result.missing); - } - for (const body of [ - { error: "upstream error" }, - { detail: "unknown error envelope" }, - { totalUsageEventsCount: 0, error: "upstream error" }, - null, - [], - "invalid", - 0, - ]) { - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => - Response.json(body), - ); - assert.isNotNull(result.error); - assert.deepStrictEqual(result.records, []); - } - }); - - it("requires a terminal Cursor page after a full page reaches the reported count", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - let requests = 0; - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { - requests++; - return Response.json( - requests === 1 - ? { - totalUsageEventsCount: 1000, - usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ - timestamp: String(1780000000000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 10, outputTokens: 5 }, - })), - } - : { totalUsageEventsCount: 1000 }, - ); - }); - assert.isNull(result.error); - assert.strictEqual(result.records.length, 1000); - assert.strictEqual(requests, 2); - }); - - it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - const event = (index: number) => ({ - timestamp: String(1780000000000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, - }); - for (const total of [2000, 2001]) { - let requests = 0; - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { - requests++; - return Response.json({ - totalUsageEventsCount: total, - usageEventsDisplay: - requests === 1 - ? Array.from({ length: 1000 }, (_, index) => event(index)) - : requests === 2 - ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) - : [event(1999)], - }); - }); - assert.isNull(result.error); - assert.strictEqual(result.records.length, total); - assert.strictEqual(requests, 3); - assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); - assert.strictEqual( - result.records.filter((record) => record.timestampMs === 1780000000999).length, - total === 2000 ? 1 : 2, - ); - assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); - } - let requests = 0; - const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { - requests++; - return Response.json({ - totalUsageEventsCount: 1001, - usageEventsDisplay: - requests === 1 - ? Array.from({ length: 1000 }, (_, index) => event(index)) - : [event(500), event(1000)], - }); - }); - assert.isNotNull(inconsistent.error); - assert.deepStrictEqual(inconsistent.records, []); - }); - - it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => - Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), - ); - assert.isNotNull(truncated.error); - assert.deepStrictEqual(truncated.records, []); - const denied = await readCursorAccountUsage( - authPath, - 0, - 1781000000000, - async () => new Response(accessToken, { status: 401 }), - ); - assert.isNotNull(denied.error); - assert.isFalse(denied.error?.includes(accessToken) ?? true); - assert.deepStrictEqual(denied.records, []); - let requested = false; - const missing = await readCursorAccountUsage( - NodePath.join(dir, "missing.json"), - 0, - 1781000000000, - async () => { - requested = true; - return Response.json({}); - }, - ); - assert.isTrue(missing.missing); - assert.isFalse(requested); - }); - - it("counts migrated OpenCode messages once and sees subsequent WAL writes", async () => { - const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "opencode.db")); - try { - db.exec( - "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; CREATE TABLE message (id TEXT, session_id TEXT, data TEXT)", - ); - const message = { - id: "msg-1", - sessionID: "session-1", - role: "assistant", - modelID: "claude-sonnet-4-5", - time: { created: 1780000000000 }, - cost: 0.25, - tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } }, - }; - const insert = db.prepare("INSERT INTO message VALUES (?, ?, ?)"); - insert.run(message.id, message.sessionID, JSON.stringify(message)); - const legacy = NodePath.join(dir, "storage", "message", message.sessionID); - await NodeFSP.mkdir(legacy, { recursive: true }); - await NodeFSP.writeFile(NodePath.join(legacy, "msg-1.json"), JSON.stringify(message)); - const first = await readOpenCodeUsage(dir, 0); - assert.isFalse(first.error); - const records = first.files.flatMap((file) => file.records); - assert.strictEqual(records.length, 1); - assert.deepStrictEqual(records[0]?.totals, { - uncachedInputTokens: 100, - cachedInputTokens: 30, - cacheCreationTokens: 10, - outputTokens: 25, - reasoningTokens: 5, - }); - assert.strictEqual(records[0]?.reportedCostUsd, 0.25); - insert.run( - "msg-2", - message.sessionID, - JSON.stringify({ ...message, id: "msg-2", time: { created: 1780000001000 } }), - ); - const next = await readOpenCodeUsage(dir, 1780000001000); - assert.isFalse(next.error); - assert.deepStrictEqual( - next.files.flatMap((file) => file.records).map((record) => record.dedupeKey), - ["opencode:msg-2"], - ); - assert.isAbove((await NodeFSP.stat(NodePath.join(dir, "opencode.db-wal"))).size, 0); - } finally { - db.close(); - } - }); - - it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => { - const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db")); - const stamp = protoNumber(1, 1780000000); - const usage = [ - ...protoNumber(2, 100), - ...protoNumber(3, 40), - ...protoNumber(4, 5), - ...protoNumber(5, 20), - ...protoNumber(9, 10), - ...protoText(11, "response-1"), - ]; - const retry = [ - ...protoNumber(1, 1026), - ...protoNumber(2, 12), - ...protoNumber(3, 3), - ...protoText(11, "retry-1"), - ]; - const generation = protoBytes(1, [ - ...protoBytes(4, usage), - ...protoText(19, "Gemini 3 Pro"), - ...protoBytes(9, protoBytes(4, stamp)), - ]); - const step = [ - ...protoBytes(9, usage), - ...protoBytes(8, stamp), - ...protoBytes(28, protoBytes(2, retry)), - ]; - try { - db.exec( - "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", - ); - db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, new Uint8Array(generation)); - db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(step)); - } finally { - db.close(); - } - const result = await readAntigravityUsage(dir, 0); - assert.deepStrictEqual(result.errors, []); - const records = result.files.flatMap((file) => file.records); - assert.strictEqual(records.length, 2); - const main = records.find((record) => record.model === "gemini-3-pro"); - assert.isDefined(main); - assert.strictEqual(main?.timestampMs, 1780000000000); - assert.strictEqual(main?.sessionId, "session-1"); - assert.deepStrictEqual(main?.totals, { - uncachedInputTokens: 100, - cachedInputTokens: 20, - cacheCreationTokens: 5, - outputTokens: 40, - reasoningTokens: 10, - }); - assert.strictEqual( - records.find((record) => record.model === "claude-opus-4-6")?.totals.uncachedInputTokens, - 12, - ); - assert.deepStrictEqual( - (await readAntigravityUsage(dir, 1780000000001)).files.flatMap((file) => file.records), - [], - ); - }); - - it("uses the matching Antigravity generation model for each model-less step", async () => { - const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "model-switch.db")); - try { - db.exec( - "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", - ); - const generation = db.prepare("INSERT INTO gen_metadata VALUES (?, ?)"); - const step = db.prepare("INSERT INTO steps VALUES (?, ?)"); - for (const [idx, name] of ["Gemini 3 Pro", "Claude Opus 4.6"].entries()) { - generation.run(idx, new Uint8Array(protoBytes(1, protoText(19, name)))); - step.run(idx, new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx)))); - } - } finally { - db.close(); - } - const result = await readAntigravityUsage(dir, 0); - assert.deepStrictEqual(result.errors, []); - assert.deepStrictEqual( - result.files.flatMap((file) => file.records).map((record) => record.model), - ["gemini-3-pro", "claude-opus-4-6"], - ); - }); - - it("merges Antigravity aliases that bridge previously separate step records", async () => { - const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "bridge.db")); - try { - db.exec( - "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", - ); - const step = db.prepare("INSERT INTO steps VALUES (?, ?)"); - step.run( - 0, - new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])), - ); - step.run( - 1, - new Uint8Array(protoBytes(9, [...protoNumber(3, 40), ...protoText(12, "provider")])), - ); - db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run( - 0, - new Uint8Array( - protoBytes(1, [ - ...protoText(19, "Gemini 3 Pro"), - ...protoBytes(4, [ - ...protoNumber(2, 50), - ...protoNumber(5, 20), - ...protoText(11, "response"), - ...protoText(12, "provider"), - ]), - ]), - ), - ); - } finally { - db.close(); - } - const result = await readAntigravityUsage(dir, 0); - assert.deepStrictEqual(result.errors, []); - const records = result.files.flatMap((file) => file.records); - assert.strictEqual(records.length, 1); - assert.deepStrictEqual(records[0]?.totals, { - uncachedInputTokens: 100, - cachedInputTokens: 20, - cacheCreationTokens: 0, - outputTokens: 40, - reasoningTokens: 0, - }); - }); - - it("merges Antigravity provider and message aliases across configured roots while keeping original ownership", async () => { - const roots = [NodePath.join(dir, "first"), NodePath.join(dir, "second")]; - for (const [index, root] of roots.entries()) { - await NodeFSP.mkdir(root); - const db = new NodeSqlite.DatabaseSync(NodePath.join(root, `session-${index}.db`)); - try { - db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); - for (const identity of [7, 12]) { - const usage = [ - ...protoNumber(1, 246), - ...protoNumber(2, index === 0 ? 100 : 150), - ...protoText(11, `response-${index}-${identity}`), - ...protoText(identity, `shared-${identity}`), - ]; - db.prepare("INSERT INTO steps VALUES (?, ?)").run( - identity, - new Uint8Array(protoBytes(9, usage)), - ); - } - } finally { - db.close(); - } - } - const result = await readAntigravityUsage(roots, 0); - assert.deepStrictEqual(result.errors, []); - assert.strictEqual(result.files.length, 2); - assert.strictEqual(result.files[0]?.root, roots[0]); - assert.strictEqual(result.files[0]?.records.length, 2); - assert.strictEqual(result.files[1]?.records.length, 0); - assert.deepStrictEqual( - result.files[0]?.records.map((record) => record.totals.uncachedInputTokens), - [150, 150], - ); - assert.isTrue(result.files[0]?.records.every((record) => record.sessionId === "session-0")); - }); - - it("upgrades Antigravity fallback timestamps before applying the date window", async () => { - for (const fallback of ["mtime", "trajectory"]) { - const path = NodePath.join(dir, `${fallback}.db`); - const db = new NodeSqlite.DatabaseSync(path); - try { - db.exec( - "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", - ); - if (fallback === "trajectory") { - db.exec("CREATE TABLE trajectory_metadata_blob (data BLOB)"); - db.prepare("INSERT INTO trajectory_metadata_blob VALUES (?)").run( - new Uint8Array(protoBytes(2, protoNumber(1, 1780000200))), - ); - } - for (const [index, seconds] of [1780000000, 1780000200].entries()) { - const usage = [...protoNumber(2, 10), ...protoText(11, `${fallback}-${index}`)]; - db.prepare("INSERT INTO steps VALUES (?, ?)").run( - index, - new Uint8Array(protoBytes(9, usage)), - ); - db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run( - index, - new Uint8Array( - protoBytes(1, [ - ...protoBytes(4, usage), - ...protoBytes(9, protoBytes(4, protoNumber(1, seconds))), - ]), - ), - ); - } - } finally { - db.close(); - } - await NodeFSP.utimes(path, 1780000000, 1780000000); - } - const result = await readAntigravityUsage(dir, 1780000100000); - assert.deepStrictEqual(result.errors, []); - const records = result.files.flatMap((file) => file.records); - assert.strictEqual(records.length, 2); - assert.deepStrictEqual( - records.map((record) => record.timestampMs), - [1780000200000, 1780000200000], - ); - }); - - const antigravityGeneration = (responseId: string) => - new Uint8Array( - protoBytes(1, [ - ...protoBytes(4, [ - ...protoNumber(2, 100), - ...protoNumber(3, 40), - ...protoText(11, responseId), - ]), - ...protoText(19, "Gemini 3 Pro"), - ...protoBytes(9, protoBytes(4, protoNumber(1, 1780000000))), - ]), - ); - - it("reuses an unchanged Antigravity database instead of decoding it again", async () => { - const path = NodePath.join(dir, "session-1.db"); - const db = new NodeSqlite.DatabaseSync(path); - try { - db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); - db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, antigravityGeneration("r-1")); - const cache = makeAntigravityUsageCache(); - assert.deepStrictEqual((await readAntigravityUsage(dir, 0, cache)).errors, []); - - // An exclusive lock makes a fresh read fail without touching the file, so - // only a cache hit can still return the earlier records. - db.exec("BEGIN EXCLUSIVE"); - assert.strictEqual((await readAntigravityUsage(dir, 0)).errors.length, 1); - const cached = await readAntigravityUsage(dir, 0, cache); - assert.deepStrictEqual(cached.errors, []); - assert.deepStrictEqual( - cached.files.flatMap((file) => file.records).map((record) => record.dedupeKey), - ["antigravity:11:r-1"], - ); - db.exec("ROLLBACK"); - } finally { - db.close(); - } - }); - - it("rereads an Antigravity database rewritten with its size and mtime restored", async () => { - const path = NodePath.join(dir, "session-1.db"); - const db = new NodeSqlite.DatabaseSync(path); - try { - db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); - db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, antigravityGeneration("r-1")); - } finally { - db.close(); - } - await NodeFSP.utimes(path, 1780000000, 1780000000); - const cache = makeAntigravityUsageCache(); - assert.deepStrictEqual((await readAntigravityUsage(dir, 0, cache)).errors, []); - - // ctime has the kernel's timestamp granularity, which can be a few - // milliseconds, so repeat the forged rewrite until it lands on a later tick - // than the cached read, as any real rewrite does. - const cached = await NodeFSP.stat(path); - do { - await NodeFSP.writeFile(path, Buffer.alloc(cached.size)); - await NodeFSP.utimes(path, 1780000000, 1780000000); - } while ((await NodeFSP.stat(path)).ctimeMs === cached.ctimeMs); - const restored = await NodeFSP.stat(path); - assert.strictEqual(restored.size, cached.size); - assert.strictEqual(restored.mtimeMs, cached.mtimeMs); - const next = await readAntigravityUsage(dir, 0, cache); - assert.deepStrictEqual(next.errors, [path]); - assert.deepStrictEqual( - next.files.flatMap((file) => file.records), - [], - ); - }); - - it("rereads an Antigravity database when only its WAL changed", async () => { - const path = NodePath.join(dir, "session-1.db"); - const db = new NodeSqlite.DatabaseSync(path); - try { - db.exec( - "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; CREATE TABLE gen_metadata (idx INTEGER, data BLOB)", - ); - const insert = db.prepare("INSERT INTO gen_metadata VALUES (?, ?)"); - insert.run(0, antigravityGeneration("r-1")); - const cache = makeAntigravityUsageCache(); - const first = await readAntigravityUsage(dir, 0, cache); - assert.strictEqual(first.files.flatMap((file) => file.records).length, 1); - - const before = await NodeFSP.stat(path); - insert.run(1, antigravityGeneration("r-2")); - const after = await NodeFSP.stat(path); - assert.strictEqual(after.size, before.size); - assert.strictEqual(after.mtimeMs, before.mtimeMs); - - const next = await readAntigravityUsage(dir, 0, cache); - assert.deepStrictEqual( - next.files.flatMap((file) => file.records).map((record) => record.dedupeKey), - ["antigravity:11:r-1", "antigravity:11:r-2"], - ); - } finally { - db.close(); - } - }); - - it("reads Antigravity step-only stores and reports malformed databases", async () => { - const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "steps.db")); - try { - db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); - const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...protoNumber(3, 5)]; - db.prepare("INSERT INTO steps VALUES (?, ?)").run( - 0, - new Uint8Array([...protoBytes(9, usage), ...protoBytes(8, protoNumber(1, 1780000000))]), - ); - } finally { - db.close(); - } - await NodeFSP.writeFile(NodePath.join(dir, "broken.db"), "not a sqlite database"); - const result = await readAntigravityUsage(dir, 0); - assert.strictEqual(result.errors.length, 1); - assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.model, "gemini-2.5-pro"); - assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.totals.outputTokens, 5); - }); - - it("ignores large values in unused Antigravity protobuf fields", async () => { - const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "large-varint.db")); - try { - db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); - const unusedField = [...protoNumber(99, 0).slice(0, -1), ...Array(9).fill(0xff), 0x01]; - const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...unusedField]; - db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(protoBytes(9, usage))); - } finally { - db.close(); - } - const result = await readAntigravityUsage(dir, 0); - assert.deepStrictEqual(result.errors, []); - assert.strictEqual( - result.files.flatMap((file) => file.records)[0]?.totals.uncachedInputTokens, - 10, + assert.isNull( + await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), TEST_FORMATS.claude), ); }); }); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index bca22468a95e..16684e3a85ab 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -19,23 +19,16 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeStringDecoder from "node:string_decoder"; -import type { UsageProviderKind } from "@t3tools/contracts"; +import type { + SelectedFields, + TranscriptUsageFormat, + UsageRecord, +} from "@t3tools/provider-core/server/usage"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { createTranscriptJsonReader } from "../project/AgentSessionJson.ts"; -import { - initialCodexScanState, - mightCarryUsage, - parseClaudeLine, - parseClaudeRecord, - parseCodexLine, - parseCodexRecord, - parseGrokLine, - parseGrokRecord, - type CodexScanState, - type UsageRecord, -} from "./usageTranscripts.ts"; - export interface TranscriptFile { readonly path: string; readonly size: number; @@ -60,8 +53,11 @@ export interface TranscriptParsePosition { readonly guardLength: number; /** FNV-1a hash of that window. */ readonly guardHash: number; - /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ - readonly codexState: CodexScanState | null; + /** + * The format's reducer state as of `resumeOffset`, encoded by its schema; + * `null` for stateless formats. + */ + readonly state: unknown; } export interface TranscriptParseResult { @@ -89,45 +85,7 @@ const NEWLINE = 0x0a; const STAT_CONCURRENCY = 32; const CARRIAGE_RETURN = 0x0d; -type SelectedFields = { readonly [key: string]: true | SelectedFields }; - -// Keep the fields consumed by usageTranscripts, including reducer state and -// dedupe/cost metadata. A selected subtree (usage) keeps future token fields. -const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = { - claude: { - type: true, - timestamp: true, - requestId: true, - sessionId: true, - costUSD: true, - message: { id: true, model: true, usage: true }, - }, - codex: { - type: true, - timestamp: true, - payload: { - type: true, - id: true, - session_id: true, - model: true, - thread_settings: { service_tier: true }, - forked_from_id: true, - source: { subagent: { thread_spawn: { parent_thread_id: true } } }, - info: { last_token_usage: true }, - }, - }, - grok: { - timestamp: true, - params: { - sessionId: true, - _meta: { agentTimestampMs: true }, - update: { sessionUpdate: true, prompt_id: true, usage: true }, - }, - }, -}; - -function selectUsageFields(provider: UsageProviderKind) { - const fields = USAGE_FIELDS[provider === "codex" || provider === "grok" ? provider : "claude"]; +function selectUsageFields(fields: SelectedFields) { return (path: ReadonlyArray): boolean => { let selected: true | SelectedFields = fields; for (const key of path) { @@ -257,14 +215,12 @@ async function guardMatches( * still match, so only appended lines are read; otherwise the whole file is * re-parsed from the start and `resumed` reports `false`. * - * Codex carries the active model on `turn_context` lines and the service tier - * on `thread_settings_applied` lines. Neither holds usage of its own, but both - * still have to pass through the reducer to keep attribution and pricing - * correct. + * A stateful format resumes with the state saved at that position; a saved + * state its schema rejects re-parses the file whole. */ -export async function readTranscriptRecords( +export async function readTranscriptRecords( filePath: string, - provider: UsageProviderKind, + format: TranscriptUsageFormat, resumeFrom?: TranscriptParsePosition, options?: { readonly streamingThresholdBytes?: number }, ): Promise { @@ -277,41 +233,31 @@ export async function readTranscriptRecords( } try { - let codexState = initialCodexScanState(); + const codec = format.state; + const decodeState = codec === undefined ? undefined : Schema.decodeUnknownOption(codec.schema); + const encodeState = codec === undefined ? undefined : Schema.encodeSync(codec.schema); + // Stateless formats never read their state argument. + let state = codec === undefined ? (undefined as State) : codec.initial(); let resumed = false; let start = 0; + const savedState = + resumeFrom === undefined || decodeState === undefined || resumeFrom.state === null + ? Option.none() + : decodeState(resumeFrom.state); if ( resumeFrom !== undefined && resumeFrom.resumeOffset > 0 && - (provider !== "codex" || resumeFrom.codexState !== null) && + (codec === undefined || Option.isSome(savedState)) && (await guardMatches(handle, resumeFrom)) ) { - if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; + if (Option.isSome(savedState)) state = savedState.value; start = resumeFrom.resumeOffset; resumed = true; } - const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { - if (provider === "codex") { - if ( - !mightCarryUsage(line, provider) && - !line.includes('"turn_context"') && - !line.includes('"thread_settings_applied"') && - !line.includes('"session_meta"') - ) { - return; - } - const record = parseCodexLine(line, state); - if (record !== null) out.push(record); - return; - } - if (!mightCarryUsage(line, provider)) return; - if (provider === "grok") { - for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); - return; - } - const record = parseClaudeLine(line); - if (record !== null) out.push(record); + const parseLine = (line: string, lineState: State, out: UsageRecord[]): void => { + if (!format.mightCarryUsage(line)) return; + for (const record of format.parseLine(line, lineState)) out.push(record); }; const toLineString = (lineBuffer: Buffer): string => { @@ -331,7 +277,7 @@ export async function readTranscriptRecords( let pendingBytes = 0; let streaming: ReturnType | undefined; let decoder: NodeStringDecoder.StringDecoder | undefined; - const selectPath = selectUsageFields(provider); + const selectPath = selectUsageFields(format.selectFields); const append = (segment: Buffer) => { if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) { @@ -350,25 +296,17 @@ export async function readTranscriptRecords( } streaming.write(decoder!.write(segment)); }; - const finish = (state: CodexScanState, out: UsageRecord[]) => { + const finish = (lineState: State, out: UsageRecord[]) => { if (streaming) { streaming.write(decoder!.end()); const projected = streaming.finish(); - if (provider === "grok") { - out.push(...parseGrokRecord(projected)); - } else { - const record = - provider === "codex" - ? parseCodexRecord(projected, state) - : parseClaudeRecord(projected); - if (record !== null) out.push(record); - } + for (const record of format.parseProjected(projected, lineState)) out.push(record); } else if (pendingBytes > 0) { const line = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks, pendingBytes); - parseLine(toLineString(line), state, out); + parseLine(toLineString(line), lineState, out); } pendingChunks = []; pendingBytes = 0; @@ -391,10 +329,10 @@ export async function readTranscriptRecords( // Most lines fit in the current chunk. Avoid buffering/streaming // machinery on this hot path. if (!streaming && pendingBytes === 0) { - parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), codexState, records); + parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), state, records); } else { append(chunk.subarray(lineStart, newlineIndex)); - finish(codexState, records); + finish(state, records); } lineStart = newlineIndex + 1; resumeOffset = scanOffset + lineStart; @@ -402,8 +340,14 @@ export async function readTranscriptRecords( scanOffset += chunk.length; } + // The unfinished tail parses against a copy, so the saved state stays at + // `resumeOffset` and the next scan replays the tail from it. + const encodedState = encodeState === undefined ? null : encodeState(state); const tailRecords: UsageRecord[] = []; - finish({ ...codexState }, tailRecords); + finish( + decodeState === undefined ? state : Option.getOrThrow(decodeState(encodedState)), + tailRecords, + ); const guardLength = Math.min(GUARD_LENGTH, resumeOffset); let guardHash = 0; @@ -420,7 +364,7 @@ export async function readTranscriptRecords( resumeOffset, guardLength, guardHash, - codexState: provider === "codex" ? codexState : null, + state: encodedState, }, resumed, }; diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts index fd23593e5523..0467c17c5d49 100644 --- a/apps/server/src/usage/usageTranscriptStreaming.test.ts +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -5,6 +5,7 @@ import * as NodePath from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { TEST_FORMATS } from "./usageTestFormats.ts"; import { readTranscriptRecords as readWithDefaultThreshold, type TranscriptParsePosition, @@ -16,7 +17,10 @@ const readTranscriptRecords = ( path: string, provider: "claude" | "codex" | "grok", position?: TranscriptParsePosition, -) => readWithDefaultThreshold(path, provider, position, { streamingThresholdBytes: 256 * 1024 }); +) => + readWithDefaultThreshold(path, TEST_FORMATS[provider], position, { + streamingThresholdBytes: 256 * 1024, + }); let dir: string; beforeEach(async () => { @@ -144,7 +148,7 @@ describe("large usage records", () => { ); const actual = await scan(large, provider); expect(actual.records).toEqual(expected.records); - expect(actual.position.codexState).toEqual(expected.position.codexState); + expect(actual.position.state).toEqual(expected.position.state); } }, ); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts deleted file mode 100644 index 8d668a139822..000000000000 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ /dev/null @@ -1,598 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - GROK_COST_USD_TICKS_PER_DOLLAR, - initialCodexScanState, - parseClaudeLine, - parseCodexLine, - parseGrokLine, - totalTokens, -} from "./usageTranscripts.ts"; - -/** Shaped after a real Claude Code assistant record. */ -function claudeLine(overrides: { - messageId: string; - contentType: string; - model?: string; - outputTokens?: number; - speed?: string; -}): string { - return JSON.stringify({ - type: "assistant", - timestamp: "2026-08-07T04:05:13.944Z", - sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", - cwd: "/home/theo/project", - message: { - id: overrides.messageId, - role: "assistant", - model: overrides.model ?? "claude-fable-5", - content: [{ type: overrides.contentType }], - usage: { - input_tokens: 2, - cache_creation_input_tokens: 66818, - cache_read_input_tokens: 1000, - output_tokens: overrides.outputTokens ?? 286, - ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), - }, - }, - }); -} - -describe("parseClaudeLine", () => { - it("extracts token totals and a dedupe key", () => { - const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); - - expect(record).not.toBeNull(); - expect(record?.provider).toBe("claude"); - expect(record?.model).toBe("claude-fable-5"); - expect(record?.totals).toEqual({ - uncachedInputTokens: 2, - cachedInputTokens: 1000, - cacheCreationTokens: 66818, - outputTokens: 286, - reasoningTokens: 0, - }); - expect(record?.dedupeKey).toBe("msg_1:"); - expect(record?.speed).toBe("standard"); - }); - - it("marks fast-mode requests", () => { - const line = (speed: string) => - parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); - - expect(line("fast")?.speed).toBe("fast"); - expect(line("standard")?.speed).toBe("standard"); - }); - - it("gives every content block of one message the same dedupe key", () => { - // T3 Code writes one record per content block, each repeating the parent - // message's full usage. Summing them would overcount ~2.4x on real data. - const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); - const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); - - expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); - expect(text?.totals).toEqual(toolUse?.totals); - }); - - it("ignores records that are not assistant messages", () => { - expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); - expect(parseClaudeLine("not json")).toBeNull(); - }); -}); - -describe("parseCodexLine", () => { - const sessionMeta = JSON.stringify({ - type: "session_meta", - timestamp: "2026-08-01T05:17:41.289Z", - payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, - }); - const turnContext = JSON.stringify({ - type: "turn_context", - timestamp: "2026-08-01T05:17:42.694Z", - payload: { type: "turn_context", model: "gpt-5.6-sol" }, - }); - const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => - JSON.stringify({ - type: "event_msg", - timestamp: "2026-08-01T05:17:49.919Z", - payload: { - type: "token_count", - info: { - last_token_usage: { - input_tokens: inputTokens, - cached_input_tokens: cached, - cache_write_input_tokens: 0, - output_tokens: output, - reasoning_output_tokens: reasoning, - }, - }, - }, - }); - - it("attributes usage to the model from the preceding turn context", () => { - const state = initialCodexScanState(); - parseCodexLine(sessionMeta, state); - parseCodexLine(turnContext, state); - const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state); - - expect(record?.provider).toBe("codex"); - expect(record?.model).toBe("gpt-5.6-sol"); - expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); - // Codex reports input_tokens inclusive of the cached portion. - expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); - expect(record?.totals.cachedInputTokens).toBe(11008); - expect(record?.totals.reasoningTokens).toBe(116); - }); - - it("skips a repeated token_count so deltas are not double counted", () => { - const state = initialCodexScanState(); - parseCodexLine(turnContext, state); - const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); - const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); - - expect(first).not.toBeNull(); - expect(repeat).toBeNull(); - }); - - it("drops usage that arrives before any model is known", () => { - const state = initialCodexScanState(); - expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); - }); - - it("does not let a pre-model event poison the duplicate signature", () => { - // A token_count before its turn_context is dropped; the identical event - // re-emitted once the model is known must still be counted. - const state = initialCodexScanState(); - expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); - parseCodexLine(turnContext, state); - expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); - }); - - it("carries the service tier from the latest thread settings", () => { - const settings = (thread_settings: Record) => - JSON.stringify({ - type: "event_msg", - timestamp: "2026-08-01T05:17:42.000Z", - payload: { type: "thread_settings_applied", thread_settings }, - }); - const state = initialCodexScanState(); - parseCodexLine(turnContext, state); - const speedAfter = (line: string, output: number) => { - parseCodexLine(line, state); - return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed; - }; - - expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard"); - expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast"); - expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast"); - // Codex omits the field when no tier was requested. - expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard"); - }); - - // A forked/subagent rollout opens with the parent's history copied in and - // every line re-stamped to the fork instant, then the ancestors' session - // metas. Counting those again multiplied usage ~1.85x on real data (#5758). - describe("forked rollouts", () => { - const meta = (overrides: { - id: string; - timestamp: string; - forkedFromId?: string; - spawnParentId?: string; - }) => - JSON.stringify({ - type: "session_meta", - timestamp: overrides.timestamp, - payload: { - type: "session_meta", - id: overrides.id, - ...(overrides.forkedFromId === undefined - ? {} - : { forked_from_id: overrides.forkedFromId }), - ...(overrides.spawnParentId === undefined - ? {} - : { - source: { - subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } }, - }, - }), - }, - }); - const stamped = (timestamp: string, line: string) => { - const parsed = JSON.parse(line) as { timestamp: string }; - parsed.timestamp = timestamp; - return JSON.stringify(parsed); - }; - - it("keeps the child session id over copied ancestor metas", () => { - const state = initialCodexScanState(); - parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state); - parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state); - parseCodexLine(turnContext, state); - const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); - - expect(record?.sessionId).toBe("child"); - }); - - it("drops the re-stamped copied burst and keeps the first real event", () => { - const state = initialCodexScanState(); - const forkInstant = "2026-08-01T05:00:00.000Z"; - parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); - parseCodexLine(meta({ id: "parent", timestamp: forkInstant }), state); - parseCodexLine(stamped(forkInstant, turnContext), state); - - // Copied history: written in one burst at the fork instant. - expect( - parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), - ).toBeNull(); - expect( - parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state), - ).toBeNull(); - - // The child's first genuine turn lands seconds later and must count. - const real = parseCodexLine( - stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)), - state, - ); - expect(real).not.toBeNull(); - expect(real?.totals.outputTokens).toBe(30); - - // Suppression never restarts, even for closely spaced later events. - const next = parseCodexLine( - stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)), - state, - ); - expect(next).not.toBeNull(); - }); - - it("recognizes subagent spawns without forked_from_id", () => { - const state = initialCodexScanState(); - const spawnInstant = "2026-08-01T05:00:00.000Z"; - parseCodexLine( - meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }), - state, - ); - parseCodexLine(stamped(spawnInstant, turnContext), state); - expect( - parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), - ).toBeNull(); - }); - - it("does not suppress anything in a rollout that is not a fork", () => { - const state = initialCodexScanState(); - parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T05:00:00.000Z" }), state); - parseCodexLine(stamped("2026-08-01T05:00:00.100Z", turnContext), state); - const record = parseCodexLine( - stamped("2026-08-01T05:00:00.200Z", tokenCount(100, 0, 10, 0)), - state, - ); - expect(record).not.toBeNull(); - }); - }); -}); - -describe("totalTokens", () => { - it("does not add reasoning on top of output", () => { - expect( - totalTokens({ - uncachedInputTokens: 10, - cachedInputTokens: 20, - cacheCreationTokens: 30, - outputTokens: 40, - reasoningTokens: 25, - }), - ).toBe(100); - }); -}); - -describe("parseGrokLine", () => { - /** Shaped after a real Grok Build `turn_completed` session update. */ - function turnCompleted(overrides?: { - sessionId?: string; - promptId?: string; - timestamp?: number; - agentTimestampMs?: number; - usage?: Record; - modelUsage?: Record> | null; - }): string { - const modelUsage = - overrides && "modelUsage" in overrides - ? overrides.modelUsage - : { - "grok-4.5-build": { - inputTokens: 20_272, - outputTokens: 272, - totalTokens: 20_544, - cachedReadTokens: 11_264, - cacheCreationTokens: 0, - reasoningTokens: 180, - costUsdTicks: 230_272_000, - }, - }; - - return JSON.stringify({ - timestamp: overrides?.timestamp ?? 1_786_372_566, - method: "_x.ai/session/update", - params: { - sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c", - update: { - sessionUpdate: "turn_completed", - prompt_id: overrides?.promptId ?? "prompt-1", - stop_reason: "end_turn", - usage: { - inputTokens: 20_272, - outputTokens: 272, - totalTokens: 20_544, - cachedReadTokens: 11_264, - cacheCreationTokens: 0, - reasoningTokens: 180, - costUsdTicks: 230_272_000, - ...(modelUsage === null ? {} : { modelUsage }), - ...overrides?.usage, - }, - }, - _meta: { - eventId: "event-1", - agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485, - }, - }, - }); - } - - it("extracts per-model totals and provider-reported cost ticks", () => { - const records = parseGrokLine(turnCompleted()); - - expect(records).toHaveLength(1); - const [record] = records; - expect(record?.provider).toBe("grok"); - expect(record?.model).toBe("grok-4.5-build"); - expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c"); - expect(record?.timestampMs).toBe(1_786_372_566_485); - expect(record?.totals).toEqual({ - uncachedInputTokens: 20_272 - 11_264, - cachedInputTokens: 11_264, - cacheCreationTokens: 0, - outputTokens: 272, - reasoningTokens: 180, - }); - expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); - expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build"); - }); - - it("emits one record per model when modelUsage has several entries", () => { - const records = parseGrokLine( - turnCompleted({ - modelUsage: { - "grok-4.5": { - inputTokens: 1000, - outputTokens: 50, - cachedReadTokens: 400, - reasoningTokens: 20, - costUsdTicks: 50_000_000, - }, - "grok-composer-2.5-fast": { - inputTokens: 200, - outputTokens: 30, - cachedReadTokens: 100, - reasoningTokens: 0, - costUsdTicks: 10_000_000, - }, - }, - }), - ); - - expect(records.map((record) => record.model).toSorted()).toEqual([ - "grok-4.5", - "grok-composer-2.5-fast", - ]); - expect(records.every((record) => record.provider === "grok")).toBe(true); - expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo( - 0.005, - 12, - ); - }); - - it("inherits top-level cost ticks for a single model without its own ticks", () => { - const records = parseGrokLine( - turnCompleted({ - modelUsage: { - "grok-4.5-build": { - inputTokens: 1000, - outputTokens: 10, - cachedReadTokens: 0, - reasoningTokens: 0, - }, - }, - usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, - }), - ); - - expect(records).toHaveLength(1); - expect(records[0]?.reportedCostUsd).toBe(1); - }); - - it("falls back to a generic grok model when modelUsage is absent", () => { - const records = parseGrokLine(turnCompleted({ modelUsage: null })); - - expect(records).toHaveLength(1); - const [record] = records; - expect(record?.provider).toBe("grok"); - expect(record?.model).toBe("grok"); - expect(record?.totals).toEqual({ - uncachedInputTokens: 20_272 - 11_264, - cachedInputTokens: 11_264, - cacheCreationTokens: 0, - outputTokens: 272, - reasoningTokens: 180, - }); - expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); - expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok"); - }); - - it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => { - const records = parseGrokLine( - turnCompleted({ - modelUsage: { - "grok-4.5": { - inputTokens: 300, - outputTokens: 0, - cachedReadTokens: 0, - reasoningTokens: 0, - }, - "grok-composer-2.5-fast": { - inputTokens: 100, - outputTokens: 0, - cachedReadTokens: 0, - reasoningTokens: 0, - }, - }, - usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, - }), - ); - - expect(records).toHaveLength(2); - const byModel = Object.fromEntries(records.map((record) => [record.model, record])); - expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); - expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); - const sum = - (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + - (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); - expect(sum).toBeCloseTo(1, 12); - }); - - it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => { - const records = parseGrokLine( - turnCompleted({ - modelUsage: { - "grok-4.5": { - inputTokens: 300, - outputTokens: 0, - cachedReadTokens: 0, - reasoningTokens: 0, - }, - "grok-composer-2.5-fast": { - inputTokens: 100, - outputTokens: 0, - cachedReadTokens: 0, - reasoningTokens: 0, - }, - "empty-sibling": { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - reasoningTokens: 0, - costUsdTicks: 0, - }, - }, - usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, - }), - ); - - expect(records).toHaveLength(2); - expect(records.every((record) => record.model !== "empty-sibling")).toBe(true); - const byModel = Object.fromEntries(records.map((record) => [record.model, record])); - expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); - expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); - const sum = - (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + - (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); - expect(sum).toBeCloseTo(1, 12); - }); - - it("allocates leftover aggregate ticks to models that omit per-model ticks", () => { - const records = parseGrokLine( - turnCompleted({ - modelUsage: { - "grok-4.5": { - inputTokens: 300, - outputTokens: 0, - cachedReadTokens: 0, - reasoningTokens: 0, - costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR, - }, - "grok-composer-2.5-fast": { - inputTokens: 100, - outputTokens: 0, - cachedReadTokens: 0, - reasoningTokens: 0, - }, - }, - usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, - }), - ); - - expect(records).toHaveLength(2); - const byModel = Object.fromEntries(records.map((record) => [record.model, record])); - expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12); - expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12); - const sum = - (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + - (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); - expect(sum).toBeCloseTo(1, 12); - }); - - it("does not invent a colliding dedupe key when prompt_id is missing", () => { - const line = JSON.stringify({ - timestamp: 1_786_372_566, - method: "_x.ai/session/update", - params: { - sessionId: "s1", - update: { - sessionUpdate: "turn_completed", - usage: { - inputTokens: 10, - outputTokens: 2, - modelUsage: { - "grok-4.5": { inputTokens: 10, outputTokens: 2 }, - }, - }, - }, - }, - }); - - expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull(); - }); - - it("ignores non-turn lines and empty usage", () => { - expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]); - expect(parseGrokLine("not json")).toEqual([]); - expect( - parseGrokLine( - turnCompleted({ - modelUsage: { - "grok-4.5-build": { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - reasoningTokens: 0, - costUsdTicks: 0, - }, - }, - }), - ), - ).toEqual([]); - }); - - it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => { - const line = JSON.stringify({ - timestamp: 1_786_372_566, - method: "_x.ai/session/update", - params: { - sessionId: "s1", - update: { - sessionUpdate: "turn_completed", - prompt_id: "p1", - usage: { - inputTokens: 10, - outputTokens: 2, - modelUsage: { - "grok-4.5": { inputTokens: 10, outputTokens: 2 }, - }, - }, - }, - }, - }); - - const records = parseGrokLine(line); - expect(records[0]?.timestampMs).toBe(1_786_372_566_000); - }); -}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts deleted file mode 100644 index 451f8a726b3e..000000000000 --- a/apps/server/src/usage/usageTranscripts.ts +++ /dev/null @@ -1,530 +0,0 @@ -/** - * Pure parsers for the provider CLIs' on-disk session transcripts. - * - * Each parser is a line-at-a-time reducer so callers can stream large files - * without materialising them. None of them touch the filesystem. - * - * @module usageTranscripts - */ -import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; - -/** - * Billing speed of a request. Faster speeds bill at a model-specific premium. - * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its - * own, more expensive tier. - */ -export type UsageSpeed = "standard" | "fast" | "ultrafast"; - -export interface UsageRecord { - readonly provider: UsageProviderKind; - readonly timestampMs: number; - readonly model: string; - /** - * Rate-table key when the provider's display name carries tiers the table - * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`. - */ - readonly rateModel?: string; - readonly sessionId: string; - readonly totals: UsageTokenTotals; - readonly reportedCostUsd: number | null; - /** Only Claude Code and Codex record a speed; other providers are `standard`. */ - readonly speed: UsageSpeed; - /** - * Key for cross-file de-duplication, or `null` when the record is inherently - * unique and needs no dedup. - */ - readonly dedupeKey: string | null; -} - -const EMPTY_TOTALS: UsageTokenTotals = { - uncachedInputTokens: 0, - cachedInputTokens: 0, - cacheCreationTokens: 0, - outputTokens: 0, - reasoningTokens: 0, -}; - -function int(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; -} - -function parseTimestampMs(value: unknown): number | null { - if (typeof value !== "string") return null; - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? null : parsed; -} - -export function totalTokens(totals: UsageTokenTotals): number { - // reasoningTokens is a subset of outputTokens and must not be added again. - return ( - totals.uncachedInputTokens + - totals.cachedInputTokens + - totals.cacheCreationTokens + - totals.outputTokens - ); -} - -/** - * Cheap substring gate applied before `JSON.parse`. - * - * Transcripts are mostly tool output; only a minority of lines carry usage. On - * a 30-day window this skips roughly half the lines outright and is worth about - * an order of magnitude. - */ -export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { - if (provider === "claude") return line.includes('"usage"'); - if (provider === "grok") return line.includes('"turn_completed"'); - return line.includes('"token_count"'); -} - -/** - * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok - * headless `total_cost_usd_ticks`. Convert to dollars for pricing. - */ -export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; - -function grokCostTicksToUsd(ticks: unknown): number | null { - if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; - return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; -} - -/* -------------------------------------------------------------------------- */ -/* Claude Code */ -/* -------------------------------------------------------------------------- */ - -/** - * Parses one line of a Claude Code transcript. - * - * T3 Code writes one record per assistant *content block*, and every one of - * those records repeats the same complete `usage` object for the parent - * message. Summing them overcounts by roughly 2.4x on a real workload, so the - * caller must drop repeats by `dedupeKey` and keep the first. - */ -export function parseClaudeLine(line: string): UsageRecord | null { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - return null; - } - return parseClaudeRecord(parsed); -} - -export function parseClaudeRecord(parsed: unknown): UsageRecord | null { - if (typeof parsed !== "object" || parsed === null) return null; - - const record = parsed as Record; - if (record["type"] !== "assistant") return null; - - const message = record["message"]; - if (typeof message !== "object" || message === null) return null; - const messageRecord = message as Record; - - const usage = messageRecord["usage"]; - if (typeof usage !== "object" || usage === null) return null; - const usageRecord = usage as Record; - - const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null) return null; - - const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; - if (model.length === 0) return null; - - const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; - const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; - // Matches ccusage: prefer the message/request pair, fall back to whichever - // half exists. Records with neither cannot be de-duplicated. - const dedupeKey = - messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; - - const cost = record["costUSD"]; - - return { - provider: "claude", - timestampMs, - model, - sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", - totals: { - uncachedInputTokens: int(usageRecord["input_tokens"]), - cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), - cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]), - outputTokens: int(usageRecord["output_tokens"]), - // Anthropic folds thinking tokens into output and does not break them out. - reasoningTokens: 0, - }, - reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, - speed: usageRecord["speed"] === "fast" ? "fast" : "standard", - dedupeKey, - }; -} - -/* -------------------------------------------------------------------------- */ -/* Codex */ -/* -------------------------------------------------------------------------- */ - -/** - * Rolling state for a single Codex rollout file. - * - * 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`. Sessions that switch either - * mid-run attribute correctly from the switch onward. - */ -export interface CodexScanState { - model: string; - speed: UsageSpeed; - sessionId: string; - lastUsageSignature: string | null; - sawSessionMeta: boolean; - /** While true, leading usage events are re-stamped copies of parent history. */ - suppressingForkCopies: boolean; - forkCopyAnchorMs: number; -} - -export function initialCodexScanState(): CodexScanState { - return { - model: "", - speed: "standard", - sessionId: "", - lastUsageSignature: null, - sawSessionMeta: false, - suppressingForkCopies: false, - forkCopyAnchorMs: 0, - }; -} - -/** - * A forked or subagent rollout opens with the parent's full history copied in, - * every line re-stamped to the fork instant. Those copies are written in one - * synchronous burst (observed gaps 0-40ms), while the child's first genuine - * usage event only lands after a real model turn (observed 5s+). One second of - * separation splits the two cleanly; `ccusage` uses the same threshold. - */ -const FORK_COPY_MAX_GAP_MS = 1000; - -/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */ -function isForkedSessionMeta(payload: Record): boolean { - if (typeof payload["forked_from_id"] === "string") return true; - const source = payload["source"]; - if (typeof source !== "object" || source === null) return false; - const subagent = (source as Record)["subagent"]; - if (typeof subagent !== "object" || subagent === null) return false; - const spawn = (subagent as Record)["thread_spawn"]; - if (typeof spawn !== "object" || spawn === null) return false; - return typeof (spawn as Record)["parent_thread_id"] === "string"; -} - -/** - * Feeds one line of a Codex rollout into `state`, returning a record when the - * line was a usage event. - * - * Deltas come from `last_token_usage`. Summing those across a session - * reconciles with the session's final `total_token_usage`, provided - * consecutive duplicate events are dropped, which this does. - */ -export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - return null; - } - return parseCodexRecord(parsed, state); -} - -export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { - if (typeof parsed !== "object" || parsed === null) return null; - - const record = parsed as Record; - const payload = record["payload"]; - if (typeof payload !== "object" || payload === null) return null; - const payloadRecord = payload as Record; - const payloadType = payloadRecord["type"]; - - if (record["type"] === "session_meta") { - // Only the first meta describes this file's own session. A forked rollout - // repeats the ancestors' metas right after it; letting those through would - // reassign every subsequent record to an ancestor session. - if (state.sawSessionMeta) return null; - state.sawSessionMeta = true; - const id = payloadRecord["id"] ?? payloadRecord["session_id"]; - if (typeof id === "string") state.sessionId = id; - const metaTimestampMs = parseTimestampMs(record["timestamp"]); - if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { - state.suppressingForkCopies = true; - state.forkCopyAnchorMs = metaTimestampMs; - } - return null; - } - - if (record["type"] === "turn_context") { - if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; - return null; - } - - if (payloadType === "thread_settings_applied") { - const settings = payloadRecord["thread_settings"]; - if (typeof settings === "object" && settings !== null) { - state.speed = codexSpeed((settings as Record)["service_tier"]); - } - return null; - } - - if (payloadType !== "token_count") return null; - - const info = payloadRecord["info"]; - if (typeof info !== "object" || info === null) return null; - const last = (info as Record)["last_token_usage"]; - if (typeof last !== "object" || last === null) return null; - const lastRecord = last as Record; - - // Only an event that is otherwise eligible may consume the duplicate - // signature. A token_count arriving before its turn_context (no model yet) - // must not poison it, or the re-emitted copy after the model is known would - // be skipped as a duplicate and those tokens never counted. - const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null) return null; - if (state.model.length === 0) return null; - - // Codex re-emits an unchanged token_count on some stream boundaries. Summing - // those would double count, so identical consecutive payloads are skipped. - const signature = JSON.stringify(lastRecord); - if (signature === state.lastUsageSignature) return null; - state.lastUsageSignature = signature; - - // In a forked rollout the copied parent history was already counted from the - // parent's own file. Drop the leading burst; the first usage event separated - // from its predecessor by a real turn's worth of time ends it for good. - if (state.suppressingForkCopies) { - if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { - state.forkCopyAnchorMs = timestampMs; - return null; - } - state.suppressingForkCopies = false; - } - - const inputTokens = int(lastRecord["input_tokens"]); - const cachedInputTokens = int(lastRecord["cached_input_tokens"]); - const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); - const outputTokens = int(lastRecord["output_tokens"]); - - const totals: UsageTokenTotals = { - // Codex reports `input_tokens` inclusive of the cached portion. - uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), - cachedInputTokens, - cacheCreationTokens, - outputTokens, - // Reported inside output_tokens, surfaced separately for the token mix. - reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])), - }; - - if (totalTokens(totals) === 0) return null; - - return { - provider: "codex", - timestampMs, - model: state.model, - sessionId: state.sessionId, - totals, - // Codex does not report cost in the rollout. - reportedCostUsd: null, - speed: state.speed, - // Events surviving the fork-copy suppression above are unique to this - // rollout, so they need no global dedup. - dedupeKey: null, - }; -} - -/** - * Maps a Codex `service_tier` to its billing speed. Codex omits the field when - * no tier was requested, which bills as standard, as do `default` and - * `standard`. `fast` is accepted as an alias of `priority`. - */ -function codexSpeed(serviceTier: unknown): UsageSpeed { - if (serviceTier === "priority" || serviceTier === "fast") return "fast"; - if (serviceTier === "ultrafast") return "ultrafast"; - return "standard"; -} - -/* -------------------------------------------------------------------------- */ -/* Grok Build */ -/* -------------------------------------------------------------------------- */ - -interface GrokUsageTotals { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cachedReadTokens: number; - readonly cacheCreationTokens: number; - readonly reasoningTokens: number; - readonly costUsdTicks: number | null; -} - -function readGrokUsageTotals(value: unknown): GrokUsageTotals | null { - if (typeof value !== "object" || value === null) return null; - const record = value as Record; - return { - inputTokens: int(record["inputTokens"]), - outputTokens: int(record["outputTokens"]), - cachedReadTokens: int(record["cachedReadTokens"]), - cacheCreationTokens: int(record["cacheCreationTokens"]), - reasoningTokens: int(record["reasoningTokens"]), - costUsdTicks: - typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"]) - ? record["costUsdTicks"] - : null, - }; -} - -function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals { - const cachedInputTokens = totals.cachedReadTokens; - const cacheCreationTokens = totals.cacheCreationTokens; - // Grok reports `inputTokens` inclusive of the cached portion, matching Codex. - const uncachedInputTokens = Math.max( - 0, - totals.inputTokens - cachedInputTokens - cacheCreationTokens, - ); - const outputTokens = totals.outputTokens; - return { - uncachedInputTokens, - cachedInputTokens, - cacheCreationTokens, - outputTokens, - reasoningTokens: Math.min(outputTokens, totals.reasoningTokens), - }; -} - -/** - * Parses one line of a Grok Build `updates.jsonl` session log. - * - * Usage lands on `turn_completed` session updates. Per-model breakdowns live - * under `usage.modelUsage`; when present each model becomes its own record. - * - * Returns every record for the line (0 or more). Callers stream line-by-line - * and flatten. - */ -export function parseGrokLine(line: string): readonly UsageRecord[] { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - return []; - } - return parseGrokRecord(parsed); -} - -export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { - if (typeof parsed !== "object" || parsed === null) return []; - - const record = parsed as Record; - const params = record["params"]; - if (typeof params !== "object" || params === null) return []; - const paramsRecord = params as Record; - - const update = paramsRecord["update"]; - if (typeof update !== "object" || update === null) return []; - const updateRecord = update as Record; - if (updateRecord["sessionUpdate"] !== "turn_completed") return []; - - const usage = updateRecord["usage"]; - if (typeof usage !== "object" || usage === null) return []; - const usageRecord = usage as Record; - - const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; - const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; - - // Prefer the high-resolution agent clock; fall back to the outer unix seconds. - const meta = paramsRecord["_meta"]; - let timestampMs: number | null = null; - if (typeof meta === "object" && meta !== null) { - const agentTimestampMs = (meta as Record)["agentTimestampMs"]; - if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) { - timestampMs = agentTimestampMs; - } - } - if (timestampMs === null) { - const timestamp = record["timestamp"]; - if (typeof timestamp === "number" && Number.isFinite(timestamp)) { - timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000; - } - } - if (timestampMs === null) return []; - - const topLevel = readGrokUsageTotals(usageRecord); - if (topLevel === null) return []; - - const modelUsage = usageRecord["modelUsage"]; - const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = []; - if (typeof modelUsage === "object" && modelUsage !== null) { - for (const [model, raw] of Object.entries(modelUsage as Record)) { - if (model.length === 0) continue; - const totals = readGrokUsageTotals(raw); - if (totals === null) continue; - modelEntries.push({ model, totals }); - } - } - - if (modelEntries.length === 0) { - if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return []; - return [ - { - provider: "grok", - timestampMs, - model: "grok", - sessionId, - totals: grokTotalsToUsage(topLevel), - reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), - speed: "standard", - // No prompt id means we cannot tell two same-second updates apart. - dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, - }, - ]; - } - - // Cost allocation: - // 1. Emitted models with their own costUsdTicks keep those values. - // 2. Remaining aggregate cost (top-level minus those per-model ticks, - // clamped at 0) is pro-rated across emitted models that lack ticks, - // by token share among the unticked models only. - // 3. When no model has per-model ticks, remaining equals the full - // aggregate and every emitted model gets a token-share slice. - // Zero-token rows are never emitted and never count toward used ticks. - const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks); - let usedTickedCostUsd = 0; - let untickedTokenDenominator = 0; - for (const entry of modelEntries) { - const tokens = totalTokens(grokTotalsToUsage(entry.totals)); - if (tokens === 0) continue; - if (entry.totals.costUsdTicks !== null) { - usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0; - } else { - untickedTokenDenominator += tokens; - } - } - const remainingCostUsd = - topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd); - - const results: UsageRecord[] = []; - for (const entry of modelEntries) { - const totals = grokTotalsToUsage(entry.totals); - if (totalTokens(totals) === 0) continue; - - let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks); - if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) { - reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator); - } - - results.push({ - provider: "grok", - timestampMs, - model: entry.model, - sessionId, - totals, - reportedCostUsd, - speed: "standard", - dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, - }); - } - return results; -} - -export { EMPTY_TOTALS }; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index a71226c06aee..fc0cdef01e5b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -1864,7 +1864,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, " leading.txt", "whitespace path\n"); yield* writeTextFile(cwd, "l.txt", "other\n"); yield* writeTextFile(cwd, "binary.dat", "binary\0data"); - if ((yield* HostProcessPlatform) !== "win32") { + if ((yield* HostProcess.Platform) !== "win32") { yield* writeTextFile(cwd, "tab\tand\nnewline.txt", "unusual path\n"); } yield* git(cwd, ["add", "."]); @@ -1906,7 +1906,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { additions: 0, deletions: 0, }); - if ((yield* HostProcessPlatform) !== "win32") { + if ((yield* HostProcess.Platform) !== "win32") { assert.deepInclude(branch.files ?? [], { path: "tab\tand\nnewline.txt", previousPath: null, @@ -2890,7 +2890,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); // NTFS rejects a newline in a file name, so there is nothing to preserve there. - it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "preserves newline characters in worktree paths when listing refs", () => Effect.gen(function* () { @@ -3965,7 +3965,7 @@ describe("Windows long path configuration", () => { const layer = GitVcsDriver.layer.pipe( Layer.provide(layerServerConfig), Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(HostProcessPlatform, platform)), + Layer.provide(Layer.succeed(HostProcess.Platform, platform)), ); return yield* Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 002311fda2cf..51c1f7b29468 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -29,7 +29,7 @@ import { type VcsRef, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; @@ -944,7 +944,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig.ServerConfig; const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; + const hostPlatform = yield* HostProcess.Platform; const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { @@ -2657,7 +2657,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "hash-object", "-t", "tree", - (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null", + (yield* HostProcess.Platform) === "win32" ? "NUL" : "/dev/null", ]); return stdout.trim(); }); @@ -3403,6 +3403,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* options?.worktreesDirectory ?? "", worktreesDir, path, + yield* HostProcess.HomeDirectory, ); if (parentDir === null) { return yield* new GitCommandError({ diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 9962044fc905..bbe95780a1f4 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, expect, it } from "@effect/vitest"; -import { HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -535,7 +535,7 @@ describe("VcsProcess.run", () => { operation: "test.output-limit", command: "node", args: ["-e", "process.stdout.write('x'.repeat(2048))"], - cwd: yield* HostProcessWorkingDirectory, + cwd: yield* HostProcess.WorkingDirectory, maxOutputBytes: 128, outputMode: "error", }).pipe(Effect.flip); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 31c806b8e78e..e827ae920985 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -11,7 +11,7 @@ import * as PlatformError from "effect/PlatformError"; import { vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -84,7 +84,7 @@ const searchWorkspaceEntries = (input: { }); const appendSeparator = (input: string) => - Effect.map(HostProcessPlatform, (platform) => + Effect.map(HostProcess.Platform, (platform) => input.endsWith("/") || input.endsWith("\\") ? input : `${input}${platform === "win32" ? "\\" : "/"}`, @@ -143,7 +143,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* writeTextFile(cwd, ".git/HEAD"); - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; if (platform !== "win32") yield* fileSystem.symlink(outside, path.join(cwd, "external")); const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; for (const directoryPath of [ diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index beac8d18a094..8f312223dae1 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -19,7 +19,7 @@ import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; @@ -110,7 +110,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu input: FilesystemBrowseInput, path: Path.Path, ): Effect.fn.Return { - const platform = yield* HostProcessPlatform; + const platform = yield* HostProcess.Platform; if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { return yield* new WorkspaceEntriesWindowsPathUnsupportedError({ cwd: input.cwd, @@ -120,7 +120,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu } if (!isExplicitRelativePath(input.partialPath)) { - return path.resolve(expandHomePath(input.partialPath)); + return path.resolve(expandHomePath(input.partialPath, yield* HostProcess.HomeDirectory)); } if (!input.cwd) { @@ -128,7 +128,10 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu partialPath: input.partialPath, }); } - return path.resolve(expandHomePath(input.cwd), input.partialPath); + return path.resolve( + expandHomePath(input.cwd, yield* HostProcess.HomeDirectory), + input.partialPath, + ); }); /** @public Service construction is part of the canonical Effect module API. */ diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 9a22a27bd37c..885b0eabb4ad 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -14,7 +14,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const layerProject = WorkspaceFileSystem.layer.pipe( @@ -102,7 +102,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i ); // Needs mkfifo; Windows has no FIFOs to reject. - it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "rejects a FIFO without blocking on open", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 000a422bcdc8..097c757a18e8 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -15,6 +15,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as HostProcess from "@t3tools/shared/HostProcess"; export class WorkspaceRootNotExistsError extends Schema.TaggedError()( "WorkspaceRootNotExistsError", @@ -152,7 +153,9 @@ export const make = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim())); + const normalizedWorkspaceRoot = path.resolve( + expandHomePath(workspaceRoot.trim(), yield* HostProcess.HomeDirectory), + ); let workspaceStat = yield* statWorkspaceRoot( workspaceRoot, normalizedWorkspaceRoot, diff --git a/apps/server/src/worktreesDirectory.ts b/apps/server/src/worktreesDirectory.ts index ee7154577458..65542b2c03bc 100644 --- a/apps/server/src/worktreesDirectory.ts +++ b/apps/server/src/worktreesDirectory.ts @@ -4,7 +4,8 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; /** * Directory new worktrees are created under: the `worktreesDirectory` - * setting, or `defaultDir` (`/worktrees`) when it is empty. Null when + * setting (`~` expands to `home`), or `defaultDir` (`/worktrees`) + * when it is empty. Null when * the setting is not an absolute path on this machine, such as `D:\worktrees` * configured for a Windows server and synced to a Linux one, or when it is a * filesystem root, which would make every path on that drive look managed. @@ -13,9 +14,10 @@ export function resolveWorktreesDirectory( setting: string, defaultDir: string, path: Path.Path, + home: string, ): string | null { if (setting === "") return defaultDir; - const expanded = expandHomePath(setting); + const expanded = expandHomePath(setting, home); if (!path.isAbsolute(expanded)) return null; const resolved = path.resolve(expanded); return isFilesystemRoot(resolved, path) ? null : resolved; @@ -34,10 +36,11 @@ export function managedWorktreesDirectories( }, defaultDir: string, path: Path.Path, + home: string, ): ReadonlyArray { const directories = new Set([defaultDir]); for (const setting of [settings.worktreesDirectory, ...settings.previousWorktreesDirectories]) { - const directory = resolveWorktreesDirectory(setting, defaultDir, path); + const directory = resolveWorktreesDirectory(setting, defaultDir, path, home); if (directory !== null) directories.add(directory); } return [...directories]; diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ae263ac15fd3..0f735efd31c7 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -4,7 +4,7 @@ import { type ServerConfig, type ServerConfigStreamEvent, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -164,7 +164,7 @@ const makeParkedWindowsLauncher = Effect.gen(function* () { ); const onWindows = (effect: Effect.Effect) => effect.pipe( - Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcess.Platform, "win32"), Effect.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 13018630dd84..33b45b21f39d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -230,11 +230,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; -import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; -import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubApi from "./sourceControl/GitHubApi.ts"; -import * as GitLabCli from "./sourceControl/GitLabCli.ts"; -import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; +import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; @@ -3206,15 +3202,7 @@ export const layer = Layer.unwrap( SourceControlDiscovery.layer.pipe( Layer.provide( SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubApi.layerWithDependencies, - GitLabCli.layer, - ForgejoCli.layer, - ), - ), + Layer.provide(SourceControlBuiltInDrivers.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provide( VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 006c19b4dbbe..aa1fbc6e7b2f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2543,7 +2543,6 @@ export default function ChatView(props: ChatViewProps) { return { id: `project-clone:${projectId}`, variant: "info", - compact: true, priority: "activity", icon: , title: `Cloning ${name}`, @@ -2567,7 +2566,6 @@ export default function ChatView(props: ChatViewProps) { return { id: `project-clone:${projectId}`, variant: cancelled ? "warning" : "error", - compact: true, icon: , title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, @@ -6057,6 +6055,13 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef) return; useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); }, [activeThreadRef, threadPanelPresentation]); + // A thread started for a link the OS opened shows its browser maximized, like a browser window. + useEffect(() => { + if (!canMaximizeRightPanel) return; + if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { + setMaximizedRightPanelThreadKey(routeThreadKey); + } + }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -7802,6 +7807,7 @@ export default function ChatView(props: ChatViewProps) { { id: `branch-mismatch:${activeBranchMismatchKey}`, variant: "info", + compact: true, icon: , title: ( @@ -8809,7 +8815,8 @@ export default function ChatView(props: ChatViewProps) { }); return; } - if (activePendingProgress) { + const sendCtx = composerRef.current?.getSendContext(); + if (activePendingProgress && sendCtx?.answeringPendingUserInput !== false) { if (directAnnotation) { notifyDirectAnnotationAttached(); return; @@ -8817,7 +8824,6 @@ export default function ChatView(props: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable) { notifyDirectAnnotationAttached(); return; @@ -11311,6 +11317,8 @@ export default function ChatView(props: ChatViewProps) { isServerThread={isServerThread} activeThreadTitle={activeThread.title} activeProject={activeProject ?? null} + parentThreadLink={parentThreadLink} + onOpenThread={onOpenRelatedThread} rightPanelOpen={inlineRightPanelOwnsTitleBar} onNewThreadInProject={handleNewThreadInActiveProject} {...(activeDraftLogicalProjectKey @@ -11775,7 +11783,7 @@ export default function ChatView(props: ChatViewProps) { aria-hidden={showComposerModelStrip ? undefined : true} inert={showComposerModelStrip ? undefined : true} className={cn( - "ps-2 group-data-model-strip-transition/composer-surface:before:backdrop-blur-(--glass-blur) group-data-model-strip-transition/composer-surface:before:bg-(--chat-composer-glass-surface)/(--glass-opacity)", + "group-data-model-strip-transition/composer-surface:before:backdrop-blur-(--glass-blur) group-data-model-strip-transition/composer-surface:before:bg-(--chat-composer-glass-surface)/(--glass-opacity)", !showComposerModelStrip && "pointer-events-none invisible absolute inset-x-0 top-full", )} diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index d34a450f6650..fdb50a6bb063 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -23,7 +23,7 @@ import { cn } from "~/lib/utils"; * span with tabIndex for a tooltip) gets the focus outline. */ const contextChipVariants = cva( - "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground disabled:cursor-default", + "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground disabled:cursor-default", { defaultVariants: { kind: "neutral" }, variants: { @@ -117,7 +117,7 @@ function ContextChipLabel({ className, ...props }: React.ComponentProps<"span">) function ContextChipAction({ className, render, ...props }: useRender.ComponentProps<"button">) { const defaultProps = { className: cn( - "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-(--context-chip-accent,var(--color-foreground))/17 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]", + "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-(--context-chip-accent,var(--color-foreground))/17 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]", className, ), "data-slot": "context-chip-action", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6e3aff08484e..5615c64ee450 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -793,9 +793,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { value={option.value} className={cn( "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]", - "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", isSelected - ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30" + ? "border-primary bg-background shadow-sm ring-2 ring-inset ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30" : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", )} > @@ -817,7 +817,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { > Repository -
+
{publishHost}/ @@ -879,9 +879,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { value={option.value} className={cn( "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", - "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", isSelected - ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30" + ? "border-primary bg-background shadow-sm ring-2 ring-inset ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30" : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", )} > diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index b78c6f955e2b..bff23a3a365e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -246,7 +246,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); @@ -769,7 +769,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP target="_blank" rel="noopener noreferrer" aria-label={prStatus.tooltip} - className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} + className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring`} onPointerDown={(event) => event.stopPropagation()} onClick={handlePrClick} > @@ -835,7 +835,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 96f6b4d2afcf..3571fec417bb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1817,7 +1817,7 @@ function TimelineMinimap({ />
) : null} - {retainingAndroidFrame && showPhone && showRestartNotice ? ( + {retainingFrame && showPhone && showRestartNotice ? (
Waiting for device video…
) : null} - {status !== "streaming" && !(retainingAndroidFrame && showPhone) ? ( + {status !== "streaming" && !(retainingFrame && showPhone) ? (
diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index d6ac3f594e94..70a55b7bc6d7 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -237,7 +237,7 @@ function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: Fil diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 9bf65102bf70..dd8cd0dab03f 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -203,7 +203,7 @@ export function MediaVideoPlayer({ type="button" aria-label={label ? `Play ${label}` : "Play video"} onClick={onOpen} - className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" > diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx index 39af63a90616..afba72c805bd 100644 --- a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -44,7 +44,7 @@ export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Pro type="button" aria-label={`Remove ${label} from history`} onClick={onRemove} - className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100" + className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring group-hover:opacity-100" > diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 19a246cf4be4..6db7e296337b 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -369,7 +369,7 @@ export function ProjectScriptEditorDialog({ type="button" className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ isSelected - ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" + ? "border-primary/70 bg-primary/10 ring-inset dark:ring-1 dark:ring-primary/30" : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" }`} onClick={() => { diff --git a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx index bcfb3b180113..48f71d139675 100644 --- a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx @@ -32,7 +32,7 @@ export function PullRequestCopyableCode({ +
+ + } + aria-label="Filter comments" + > + + {filteredOutCount > 0 ? `${filteredOutCount} hidden` : "Filter"} + + + + Show + setCommentFilter((value) => ({ ...value, bots }))} + > + Bot comments + + + setCommentFilter((value) => ({ ...value, resolved })) + } + > + Resolved or dismissed + + + + + +
} > {activityPending ? ( @@ -950,6 +930,12 @@ export function PullRequestSummaryTab({ ) : null} {detail.comments.length === 0 ? (

No comments yet.

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

+ {filteredOutCount === 1 + ? "The only comment is hidden by the filter." + : `All ${filteredOutCount} comments are hidden by the filter.`} +

) : (
{commentOrder === "oldest" ? showOldestCommentsButton : null} @@ -965,87 +951,6 @@ export function PullRequestSummaryTab({ Show only {COMMENT_PAGE} recent comments ) : null} - {botComments.length > 0 ? ( - { - if (open) setOpenedBotGroup(detail.url); - }} - > -
- {openedBotGroup === detail.url - ? orderPullRequestComments(recentBotComments, commentOrder).map( - renderComment, - ) - : null} - {hiddenBotCommentCount > 0 ? ( - - ) : null} - {shownBotComments > COMMENT_PAGE ? ( - - ) : null} -
-
- ) : null} - {finishedComments.length > 0 ? ( - -
- {orderPullRequestComments(finishedComments, commentOrder).map((comment) => { - const thread = threadByCommentId.get(comment.id); - return ( - - } - /> - ); - })} -
-
- ) : null}
)} diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index c6174b54f135..69421fefbbce 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -376,7 +376,7 @@ function CommitEvent({ return ( diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx index cb6f09af1001..cf4e91fcf534 100644 --- a/apps/web/src/components/settings/FoldedSettingsSection.tsx +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -46,7 +46,7 @@ export function FoldedSettingsSection({ className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4" >

- + {title} }>
- + @@ -818,7 +818,7 @@ function KeybindingKeyControl({ onClick={() => setDraft({ isRecording: true })} aria-label={`Edit shortcut for ${commandLabel(row.command)}: ${formatShortcutLabel(row.binding.shortcut)}`} className={cn( - "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/24", + "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring/24", pillClassName, )} > diff --git a/apps/web/src/components/settings/PanelAnimationsPreview.tsx b/apps/web/src/components/settings/PanelAnimationsPreview.tsx index 34178f9b54c7..9bc2478923b1 100644 --- a/apps/web/src/components/settings/PanelAnimationsPreview.tsx +++ b/apps/web/src/components/settings/PanelAnimationsPreview.tsx @@ -11,7 +11,7 @@ export function PanelAnimationsPreview({ durationMs }: { durationMs: number }) {